Files
JE-Skin/src/lib/components/SignalChart.svelte
2026-05-12 18:38:22 +08:00

470 lines
12 KiB
Svelte

<script lang="ts">
import type { HudSignalPanel, SignalTone } from "$lib/types/hud";
export let panel: HudSignalPanel;
export let panelIndex = 0;
export let locale: "zh-CN" | "en-US" = "zh-CN";
$: signalI18n = locale === "zh-CN"
? { now: "当前", max: "最大", min: "最小", total: "合计" }
: { now: "Now", max: "Max", min: "Min", total: "TOTAL" };
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 === "TOTAL" ? signalI18n.total : 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">{signalI18n.now}</span>
<span class="value">{latestValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-lime"></span>
<span class="metric-label">{signalI18n.max}</span>
<span class="value">{maxValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-orange"></span>
<span class="metric-label">{signalI18n.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(19rem, 27vw, 26rem));
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;
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);
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;
}
.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(16rem, 32vw, 21rem));
aspect-ratio: 1.5 / 1;
min-block-size: 10.1rem;
}
.chart-stage {
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
}
}
@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;
background: linear-gradient(160deg, rgb(var(--hud-surface-alt-rgb) / 0.86) 0%, rgb(var(--hud-surface-deep-rgb) / 0.9) 100%);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-strong-rgb) / 0.08), 0 0 8px rgb(var(--hud-glow-rgb) / 0.1);
}
.scan-haze {
display: none;
}
.chart-stage {
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.78), rgb(var(--hud-surface-deep-rgb) / 0.88));
box-shadow: none;
}
}
</style>