453 lines
11 KiB
Svelte
453 lines
11 KiB
Svelte
<script lang="ts">
|
|
import type { HudSignalPanel, SignalTone } from "$lib/types/hud";
|
|
|
|
export let panel: HudSignalPanel;
|
|
export let panelIndex = 0;
|
|
|
|
const viewportWidth = 100;
|
|
const viewportHeight = 36;
|
|
|
|
const toneColorMap: Record<SignalTone, string> = {
|
|
cyan: "62 232 255",
|
|
lime: "133 255 68",
|
|
orange: "255 91 63",
|
|
violet: "171 118 255",
|
|
gold: "255 206 84",
|
|
rose: "255 108 176"
|
|
};
|
|
|
|
interface SeriesRenderShape {
|
|
id: string;
|
|
tone: SignalTone;
|
|
linePath: string;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function formatMetric(value: number | null): string {
|
|
return value === null ? "--" : value.toFixed(1);
|
|
}
|
|
|
|
function resolveBounds(seriesCollection: HudSignalPanel["series"]): { min: number; max: number } {
|
|
const allPoints = seriesCollection.flatMap((series) => series.points);
|
|
|
|
if (allPoints.length === 0) {
|
|
return { min: 0, max: 1 };
|
|
}
|
|
|
|
let min = Math.min(...allPoints);
|
|
let max = Math.max(...allPoints);
|
|
|
|
if (Math.abs(max - min) < 0.001) {
|
|
const padding = Math.max(Math.abs(max) * 0.05, 1);
|
|
min -= padding;
|
|
max += padding;
|
|
} else {
|
|
const padding = Math.max((max - min) * 0.08, 0.5);
|
|
min -= padding;
|
|
max += padding;
|
|
}
|
|
|
|
return { min, max };
|
|
}
|
|
|
|
function convertPoints(rawPoints: number[], bounds: { min: number; max: number }): Array<{ x: number; y: number }> {
|
|
if (!rawPoints.length) {
|
|
return [];
|
|
}
|
|
|
|
if (rawPoints.length === 1) {
|
|
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
|
|
}
|
|
|
|
const stepX = viewportWidth / (rawPoints.length - 1);
|
|
const span = Math.max(bounds.max - bounds.min, 1);
|
|
|
|
return rawPoints.map((point, index) => ({
|
|
x: Math.round(index * stepX * 100) / 100,
|
|
y:
|
|
Math.round(
|
|
clamp(viewportHeight - ((point - bounds.min) / span) * viewportHeight, 1, viewportHeight - 1) * 100
|
|
) / 100
|
|
}));
|
|
}
|
|
|
|
function createLinePath(rawPoints: number[], bounds: { min: number; max: number }): string {
|
|
const points = convertPoints(rawPoints, bounds);
|
|
if (!points.length) {
|
|
return "";
|
|
}
|
|
|
|
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
|
}
|
|
|
|
$: bounds = resolveBounds(panel.series);
|
|
$: renderedSeries = panel.series.map(
|
|
(series): SeriesRenderShape => ({
|
|
id: series.id,
|
|
tone: series.tone,
|
|
linePath: createLinePath(series.points, bounds)
|
|
})
|
|
);
|
|
$: latestValue = formatMetric(panel.latest);
|
|
$: minValue = formatMetric(panel.min);
|
|
$: maxValue = formatMetric(panel.max);
|
|
</script>
|
|
|
|
<article
|
|
class="signal-panel side-{panel.side}"
|
|
class:is-active={panel.active}
|
|
aria-hidden={!panel.active}
|
|
style="--panel-index: {panelIndex};"
|
|
>
|
|
<header class="panel-head">
|
|
<div class="head-text">
|
|
<p class="panel-code">{panel.code}</p>
|
|
<p class="panel-title">{panel.title}</p>
|
|
</div>
|
|
|
|
<div class="icon-layer" aria-hidden="true">
|
|
{#each panel.icons as icon (icon.id)}
|
|
<span class="icon-chip tone-{icon.tone}">{icon.label}</span>
|
|
{/each}
|
|
</div>
|
|
</header>
|
|
|
|
<div class="chart-stage">
|
|
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={panel.title}>
|
|
<g class="grid-line-group">
|
|
{#each [6, 12, 18, 24, 30] as y}
|
|
<line x1="0" y1={y} x2={viewportWidth} y2={y}></line>
|
|
{/each}
|
|
</g>
|
|
|
|
{#each renderedSeries as series (series.id)}
|
|
{#if series.linePath}
|
|
<path d={series.linePath} class="series-line tone-{series.tone}" />
|
|
{/if}
|
|
{/each}
|
|
</svg>
|
|
|
|
<div class="scan-haze" aria-hidden="true"></div>
|
|
</div>
|
|
|
|
<footer class="panel-foot">
|
|
<p class="foot-item">
|
|
<span class="dot tone-cyan"></span>
|
|
<span class="metric-label">Now</span>
|
|
<span class="value">{latestValue}</span>
|
|
</p>
|
|
<p class="foot-item">
|
|
<span class="dot tone-lime"></span>
|
|
<span class="metric-label">Max</span>
|
|
<span class="value">{maxValue}</span>
|
|
</p>
|
|
<p class="foot-item">
|
|
<span class="dot tone-orange"></span>
|
|
<span class="metric-label">Min</span>
|
|
<span class="value">{minValue}</span>
|
|
</p>
|
|
</footer>
|
|
</article>
|
|
|
|
<style>
|
|
.signal-panel {
|
|
--offset-x: 12%;
|
|
--enter-ms: 1800ms;
|
|
--fade-ms: 1000ms;
|
|
overflow: hidden;
|
|
inline-size: min(100%, clamp(16.8rem, 23vw, 22rem));
|
|
aspect-ratio: 1.44 / 1;
|
|
min-block-size: 11.8rem;
|
|
justify-self: start;
|
|
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
|
|
border-radius: 0.92rem;
|
|
padding: 0.56rem 0.62rem 0.58rem;
|
|
background:
|
|
linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.76) 0%, rgb(var(--hud-surface-rgb) / 0.62) 48%, rgb(var(--hud-surface-deep-rgb) / 0.76) 100%),
|
|
radial-gradient(circle at 12% 0, rgb(var(--hud-glow-rgb) / 0.1), transparent 40%);
|
|
box-shadow:
|
|
inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08),
|
|
inset 0 -24px 32px rgb(0 0 0 / 0.48),
|
|
0 0 14px rgb(var(--hud-glow-rgb) / 0.14);
|
|
opacity: 1;
|
|
transform: translateX(0) scale(1) rotate(0);
|
|
transition:
|
|
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1),
|
|
transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
|
|
border-color 460ms ease,
|
|
filter 760ms ease;
|
|
transition-delay: calc(var(--panel-index) * 140ms);
|
|
}
|
|
|
|
.signal-panel.side-left {
|
|
--offset-x: -132%;
|
|
}
|
|
|
|
.signal-panel.side-right {
|
|
--offset-x: 132%;
|
|
justify-self: end;
|
|
}
|
|
|
|
.signal-panel:not(.is-active) {
|
|
border-color: transparent;
|
|
opacity: 0;
|
|
transform: translateX(var(--offset-x)) scale(0.98) rotate(-0.6deg);
|
|
filter: blur(1.3px);
|
|
pointer-events: none;
|
|
transition-delay: 0ms;
|
|
}
|
|
|
|
.panel-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 0.4rem;
|
|
margin-block-end: 0.4rem;
|
|
}
|
|
|
|
.head-text {
|
|
min-width: 0;
|
|
}
|
|
|
|
.panel-code {
|
|
margin: 0;
|
|
font-size: 0.63rem;
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.88);
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.panel-title {
|
|
margin: 0.12rem 0 0;
|
|
font-size: 0.75rem;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.icon-layer {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.26rem;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
}
|
|
|
|
.icon-chip {
|
|
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.44);
|
|
border-radius: 999px;
|
|
padding: 0.08rem 0.36rem;
|
|
font-size: 0.58rem;
|
|
letter-spacing: 0.08em;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.94);
|
|
background: rgb(var(--hud-surface-rgb) / 0.66);
|
|
}
|
|
|
|
.icon-chip.tone-cyan {
|
|
border-color: rgb(var(--hud-cyan-rgb) / 0.54);
|
|
}
|
|
|
|
.icon-chip.tone-lime {
|
|
border-color: rgb(var(--hud-lime-rgb) / 0.56);
|
|
}
|
|
|
|
.icon-chip.tone-orange {
|
|
border-color: rgb(var(--hud-orange-rgb) / 0.58);
|
|
}
|
|
|
|
.icon-chip.tone-violet {
|
|
border-color: rgb(171 118 255 / 0.58);
|
|
}
|
|
|
|
.icon-chip.tone-gold {
|
|
border-color: rgb(255 206 84 / 0.58);
|
|
}
|
|
|
|
.icon-chip.tone-rose {
|
|
border-color: rgb(255 108 176 / 0.58);
|
|
}
|
|
|
|
.chart-stage {
|
|
position: relative;
|
|
block-size: clamp(6.4rem, 9vw, 8.2rem);
|
|
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
|
|
border-radius: 0.62rem;
|
|
overflow: hidden;
|
|
background:
|
|
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.68), rgb(var(--hud-surface-deep-rgb) / 0.78)),
|
|
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.09), transparent 45%);
|
|
}
|
|
|
|
svg {
|
|
display: block;
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
}
|
|
|
|
.grid-line-group line {
|
|
stroke: rgb(var(--hud-border-strong-rgb) / 0.16);
|
|
stroke-width: 0.45;
|
|
}
|
|
|
|
.series-line {
|
|
fill: none;
|
|
stroke-width: 1.3;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
filter: drop-shadow(0 0 2px rgb(0 0 0 / 0.42));
|
|
}
|
|
|
|
.series-line.tone-cyan {
|
|
stroke: rgb(var(--hud-cyan-rgb) / 0.95);
|
|
}
|
|
|
|
.series-line.tone-lime {
|
|
stroke: rgb(var(--hud-lime-rgb) / 0.94);
|
|
}
|
|
|
|
.series-line.tone-orange {
|
|
stroke: rgb(var(--hud-orange-rgb) / 0.94);
|
|
}
|
|
|
|
.series-line.tone-violet {
|
|
stroke: rgb(171 118 255 / 0.94);
|
|
}
|
|
|
|
.series-line.tone-gold {
|
|
stroke: rgb(255 206 84 / 0.94);
|
|
}
|
|
|
|
.series-line.tone-rose {
|
|
stroke: rgb(255 108 176 / 0.94);
|
|
}
|
|
|
|
.scan-haze {
|
|
position: absolute;
|
|
inset: 0;
|
|
background:
|
|
repeating-linear-gradient(
|
|
180deg,
|
|
rgb(var(--hud-border-strong-rgb) / 0.04) 0,
|
|
rgb(var(--hud-border-strong-rgb) / 0.04) 1px,
|
|
transparent 1px,
|
|
transparent 3px
|
|
),
|
|
linear-gradient(180deg, transparent 0%, rgb(var(--hud-glow-rgb) / 0.06) 50%, transparent 100%);
|
|
background-size: 100% 100%, 100% 100%;
|
|
mix-blend-mode: screen;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.panel-foot {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.8rem;
|
|
margin: 0.4rem 0 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.foot-item {
|
|
margin: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.9);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.dot {
|
|
inline-size: 0.34rem;
|
|
block-size: 0.34rem;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.dot.tone-cyan {
|
|
background: rgb(var(--hud-cyan-rgb));
|
|
}
|
|
|
|
.dot.tone-lime {
|
|
background: rgb(var(--hud-lime-rgb));
|
|
}
|
|
|
|
.dot.tone-orange {
|
|
background: rgb(var(--hud-orange-rgb));
|
|
}
|
|
|
|
.metric-label {
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.value {
|
|
min-inline-size: 2.4rem;
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.signal-panel {
|
|
inline-size: min(100%, clamp(14rem, 30vw, 17rem));
|
|
aspect-ratio: 1.5 / 1;
|
|
min-block-size: 10.1rem;
|
|
}
|
|
}
|
|
|
|
@media (max-height: 900px) {
|
|
.signal-panel {
|
|
inline-size: min(100%, clamp(15rem, 22vw, 18.5rem));
|
|
min-block-size: 10.6rem;
|
|
}
|
|
|
|
.chart-stage {
|
|
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
|
|
}
|
|
}
|
|
|
|
@media (max-height: 760px) {
|
|
.signal-panel {
|
|
inline-size: min(100%, clamp(13.8rem, 20vw, 16.5rem));
|
|
min-block-size: 9.8rem;
|
|
padding: 0.46rem 0.5rem 0.5rem;
|
|
}
|
|
|
|
.panel-foot {
|
|
margin-block-start: 0.28rem;
|
|
}
|
|
|
|
.chart-stage {
|
|
block-size: clamp(5rem, 6.6vw, 6rem);
|
|
}
|
|
}
|
|
|
|
@media (max-height: 680px) {
|
|
.signal-panel {
|
|
inline-size: min(100%, clamp(12.8rem, 18vw, 15rem));
|
|
min-block-size: 8.7rem;
|
|
padding: 0.4rem 0.46rem 0.44rem;
|
|
}
|
|
|
|
.panel-head {
|
|
margin-block-end: 0.26rem;
|
|
}
|
|
|
|
.panel-foot {
|
|
margin-block-start: 0.18rem;
|
|
gap: 0.56rem;
|
|
}
|
|
|
|
.chart-stage {
|
|
block-size: clamp(4.4rem, 5.6vw, 5.4rem);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.signal-panel {
|
|
inline-size: 100%;
|
|
aspect-ratio: 1.7 / 1;
|
|
min-block-size: 0;
|
|
}
|
|
}
|
|
</style>
|