Files
JE-Skin/src/lib/components/SummaryCurve.svelte
2026-04-03 16:40:48 +08:00

585 lines
15 KiB
Svelte

<script lang="ts">
import type { HudSummary } from "$lib/types/hud";
export let summary: HudSummary;
export let side: "left" | "right" = "right";
export let panelIndex = 0;
export let xValues: number[] | null = null;
export let yValues: number[] | null = null;
const viewportWidth = 100;
const viewportHeight = 36;
const horizontalInset = 2;
const verticalInset = 2;
interface CurveSample {
x: number;
y: number;
}
interface PlotPoint {
x: number;
y: number;
}
interface AxisTick {
value: number;
label: string;
plotX: number;
plotY: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function formatValue(value: number | null): string {
if (value === null) {
return "--";
}
return value.toFixed(1);
}
function formatAxisValue(value: number, axis: "x" | "y"): string {
if (!Number.isFinite(value)) {
return "--";
}
if (axis === "x") {
return String(Math.round(value));
}
if (Math.abs(value) >= 1000) {
const compact = Math.round((value / 1000) * 10) / 10;
return Number.isInteger(compact) ? `${compact.toFixed(0)}k` : `${compact.toFixed(1)}k`;
}
return Math.abs(value) >= 100 ? Math.round(value).toString() : value.toFixed(1);
}
function resolveDataBounds(values: number[]): { min: number; max: number } {
if (values.length === 0) {
return { min: 0, max: 1 };
}
return {
min: Math.min(...values),
max: Math.max(...values)
};
}
function resolveBounds(values: number[]): { min: number; max: number } {
if (values.length === 0) {
return { min: 0, max: 1 };
}
const min = Math.min(...values);
const max = Math.max(...values);
if (Math.abs(max - min) < 0.001) {
const padding = Math.max(Math.abs(max) * 0.05, 1);
return { min: min - padding, max: max + padding };
}
return { min, max };
}
function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
const span = bounds.max - bounds.min;
const chartWidth = viewportWidth - horizontalInset * 2;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedX = horizontalInset + ratio * chartWidth;
return Math.round(clamp(mappedX, horizontalInset, viewportWidth - horizontalInset) * 100) / 100;
}
function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
const span = bounds.max - bounds.min;
const chartHeight = viewportHeight - verticalInset * 2;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedY = viewportHeight - verticalInset - ratio * chartHeight;
return Math.round(clamp(mappedY, verticalInset, viewportHeight - verticalInset) * 100) / 100;
}
function buildSamples(rawYValues: number[], rawXValues: number[]): CurveSample[] {
if (!rawYValues.length) {
return [];
}
return rawYValues.map((rawY, index) => {
const x = rawXValues[index];
const y = Number.isFinite(rawY) ? Number(rawY) : 0;
const resolvedX = Number.isFinite(x) ? Number(x) : index + 1;
return { x: resolvedX, y };
});
}
function convertPoints(
samples: CurveSample[],
xBounds: { min: number; max: number },
yBounds: { min: number; max: number }
): PlotPoint[] {
if (samples.length === 0) {
return [];
}
if (samples.length === 1) {
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
}
return samples.map((sample) => {
return {
x: mapXToViewport(sample.x, xBounds),
y: mapYToViewport(sample.y, yBounds)
};
});
}
function buildYAxisTicks(
yScaleBounds: { min: number; max: number },
yDataBounds: { min: number; max: number }
): AxisTick[] {
const hasRange = Math.abs(yDataBounds.max - yDataBounds.min) >= 0.001;
const tickValues = hasRange
? [yDataBounds.max, (yDataBounds.max + yDataBounds.min) / 2, yDataBounds.min]
: [yScaleBounds.max, (yScaleBounds.max + yScaleBounds.min) / 2, yScaleBounds.min];
return tickValues.map((value) => ({
value,
label: formatAxisValue(value, "y"),
plotX: horizontalInset,
plotY: mapYToViewport(value, yScaleBounds)
}));
}
function buildXAxisTicks(samples: CurveSample[], xScaleBounds: { min: number; max: number }): AxisTick[] {
if (!samples.length) {
return [];
}
const first = samples[0].x;
const middle = samples[Math.floor((samples.length - 1) / 2)].x;
const last = samples[samples.length - 1].x;
const tickValues = [first, middle, last];
return tickValues.map((value) => ({
value,
label: formatAxisValue(value, "x"),
plotX: mapXToViewport(value, xScaleBounds),
plotY: viewportHeight - 1.2
}));
}
function createLinePath(points: PlotPoint[]): string {
if (points.length === 0) {
return "";
}
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
}
function createAreaPath(points: PlotPoint[]): string {
if (points.length < 2) {
return "";
}
const linePath = createLinePath(points);
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`;
}
$: sourceYValues = yValues && yValues.length ? yValues : summary.points;
$: sourceXValues = xValues && xValues.length ? xValues : summary.xValues ?? [];
$: samples = buildSamples(sourceYValues, sourceXValues);
$: sampleCount = samples.length;
$: xScaleBounds = resolveBounds(samples.map((sample) => sample.x));
$: yScaleBounds = resolveBounds(samples.map((sample) => sample.y));
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds);
$: linePath = createLinePath(plotPoints);
$: areaPath = createAreaPath(plotPoints);
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
$: yAxisTicks = sampleCount > 0 ? buildYAxisTicks(yScaleBounds, yDataBounds) : [];
$: xAxisTicks = sampleCount > 0 ? buildXAxisTicks(samples, xScaleBounds) : [];
$: latestValue = formatValue(summary.latest);
$: minValue = formatValue(summary.min);
$: maxValue = formatValue(summary.max);
</script>
<article
class="signal-panel summary-panel side-{side}"
class:is-empty={sampleCount === 0}
aria-hidden={sampleCount === 0}
style="--panel-index: {panelIndex};"
>
<header class="panel-head">
<div class="head-text">
<p class="panel-code">TOT</p>
<p class="panel-title">{summary.label}</p>
</div>
<div class="icon-layer" aria-hidden="true">
<span class="icon-chip tone-cyan">NOW</span>
<span class="icon-chip tone-lime">MIN</span>
<span class="icon-chip tone-orange">MAX</span>
</div>
</header>
<div class="chart-stage">
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={summary.label}>
<defs>
<linearGradient id="summary-fill" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgb(62 232 255 / 0.28)" />
<stop offset="100%" stop-color="rgb(62 232 255 / 0.02)" />
</linearGradient>
</defs>
<g class="grid-lines" aria-hidden="true">
{#each [6, 12, 18, 24, 30] as y}
<line x1="0" y1={y} x2={viewportWidth} y2={y}></line>
{/each}
</g>
{#if areaPath}
<path d={areaPath} class="summary-area"></path>
{/if}
{#if linePath}
<path d={linePath} class="summary-line"></path>
{/if}
{#if lastPoint}
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
{/if}
<g class="axis-labels" aria-hidden="true">
{#each yAxisTicks as tick, index (`y-${index}`)}
<text class="axis-label y-axis-label" x={tick.plotX + 0.8} y={tick.plotY - 0.35} text-anchor="start">
{tick.label}
</text>
{/each}
{#each xAxisTicks as tick, index (`x-${index}`)}
<text
class="axis-label x-axis-label"
x={tick.plotX}
y={tick.plotY}
text-anchor={index === 0 ? "start" : index === xAxisTicks.length - 1 ? "end" : "middle"}
>
{tick.label}
</text>
{/each}
</g>
</svg>
{#if sampleCount === 0}
<div class="empty-state">
<span>Waiting</span>
</div>
{/if}
</div>
<footer class="panel-foot">
<p class="foot-item">
<span class="dot tone-cyan"></span>
<span class="metric-text">Now</span>
<span class="value">{latestValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-lime"></span>
<span class="metric-text">Min</span>
<span class="value">{minValue}</span>
</p>
<p class="foot-item">
<span class="dot tone-orange"></span>
<span class="metric-text">Max</span>
<span class="value">{maxValue}</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;
display: grid;
grid-template-rows: auto auto auto;
gap: 0.4rem;
padding: 0.56rem 0.62rem 0.58rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
border-radius: 0.92rem;
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;
}
.summary-panel.is-empty {
opacity: 0.82;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.4rem;
margin-block-end: 0;
}
.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;
align-items: center;
justify-content: flex-end;
gap: 0.26rem;
}
.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);
}
.chart-stage {
position: relative;
block-size: clamp(6.4rem, 9vw, 8.2rem);
overflow: hidden;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
border-radius: 0.62rem;
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-lines line {
stroke: rgb(var(--hud-border-strong-rgb) / 0.16);
stroke-width: 0.45;
}
.summary-area {
fill: url(#summary-fill);
}
.summary-line {
fill: none;
stroke: rgb(var(--hud-cyan-rgb) / 0.96);
stroke-width: 1.35;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgb(var(--hud-cyan-rgb) / 0.22));
}
.summary-dot {
fill: rgb(var(--hud-lime-rgb) / 0.98);
filter: drop-shadow(0 0 6px rgb(var(--hud-lime-rgb) / 0.3));
}
.axis-label {
fill: rgb(var(--hud-text-main-rgb) / 0.88);
font-size: 2.8px;
font-weight: 500;
letter-spacing: 0.02em;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
0 0 4px rgb(0 0 0 / 0.3);
}
.y-axis-label {
fill: rgb(var(--hud-text-dim-rgb) / 0.84);
}
.x-axis-label {
fill: rgb(var(--hud-text-dim-rgb) / 0.9);
}
.empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(var(--hud-text-dim-rgb) / 0.76);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.06), rgb(var(--hud-surface-deep-rgb) / 0.18));
}
.panel-foot {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin: 0;
padding: 0;
}
.foot-item {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: rgb(var(--hud-text-main-rgb) / 0.9);
font-size: 0.62rem;
letter-spacing: 0.04em;
}
.metric-text {
color: rgb(var(--hud-text-dim-rgb) / 0.82);
text-transform: uppercase;
}
.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));
}
.value {
min-inline-size: 2.6rem;
}
@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>