first commit
This commit is contained in:
436
src/lib/components/SummaryCurve.svelte
Normal file
436
src/lib/components/SummaryCurve.svelte
Normal file
@@ -0,0 +1,436 @@
|
||||
<script lang="ts">
|
||||
import type { HudSummary } from "$lib/types/hud";
|
||||
|
||||
export let summary: HudSummary;
|
||||
export let side: "left" | "right" = "right";
|
||||
export let panelIndex = 0;
|
||||
|
||||
const viewportWidth = 100;
|
||||
const viewportHeight = 36;
|
||||
const verticalInset = 2;
|
||||
|
||||
interface PlotPoint {
|
||||
x: number;
|
||||
y: 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 resolveBounds(points: number[]): { min: number; max: number } {
|
||||
if (points.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
|
||||
const min = Math.min(...points);
|
||||
const max = Math.max(...points);
|
||||
|
||||
if (Math.abs(max - min) < 0.001) {
|
||||
return { min: min - 1, max: max + 1 };
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function convertPoints(rawPoints: number[]): PlotPoint[] {
|
||||
if (rawPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (rawPoints.length === 1) {
|
||||
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
|
||||
}
|
||||
|
||||
const { min, max } = resolveBounds(rawPoints);
|
||||
const span = max - min;
|
||||
const chartHeight = viewportHeight - verticalInset * 2;
|
||||
const stepX = viewportWidth / (rawPoints.length - 1);
|
||||
|
||||
return rawPoints.map((point, index) => {
|
||||
const ratio = span <= 0 ? 0.5 : (point - min) / span;
|
||||
const y = viewportHeight - verticalInset - ratio * chartHeight;
|
||||
|
||||
return {
|
||||
x: Math.round(index * stepX * 100) / 100,
|
||||
y: Math.round(clamp(y, verticalInset, viewportHeight - verticalInset) * 100) / 100
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
$: plotPoints = convertPoints(summary.points);
|
||||
$: linePath = createLinePath(plotPoints);
|
||||
$: areaPath = createAreaPath(plotPoints);
|
||||
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
|
||||
$: latestValue = formatValue(summary.latest);
|
||||
$: minValue = formatValue(summary.min);
|
||||
$: maxValue = formatValue(summary.max);
|
||||
$: sampleCount = summary.points.length;
|
||||
</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}
|
||||
</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(130 174 202 / 0.42);
|
||||
border-radius: 0.92rem;
|
||||
background:
|
||||
linear-gradient(160deg, rgb(10 18 26 / 0.76) 0%, rgb(3 8 12 / 0.62) 48%, rgb(1 2 4 / 0.76) 100%),
|
||||
radial-gradient(circle at 12% 0, rgb(62 232 255 / 0.1), transparent 40%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(165 224 255 / 0.08),
|
||||
inset 0 -24px 32px rgb(0 0 0 / 0.48),
|
||||
0 0 14px rgb(62 232 255 / 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(153 188 211 / 0.88);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(225 243 255 / 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(138 178 204 / 0.44);
|
||||
border-radius: 999px;
|
||||
padding: 0.08rem 0.36rem;
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(209 237 255 / 0.94);
|
||||
background: rgb(5 13 20 / 0.66);
|
||||
}
|
||||
|
||||
.icon-chip.tone-cyan {
|
||||
border-color: rgb(62 232 255 / 0.54);
|
||||
}
|
||||
|
||||
.icon-chip.tone-lime {
|
||||
border-color: rgb(133 255 68 / 0.56);
|
||||
}
|
||||
|
||||
.icon-chip.tone-orange {
|
||||
border-color: rgb(255 91 63 / 0.58);
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
position: relative;
|
||||
block-size: clamp(6.4rem, 9vw, 8.2rem);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(132 174 200 / 0.32);
|
||||
border-radius: 0.62rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(8 17 26 / 0.68), rgb(1 6 10 / 0.78)),
|
||||
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.09), transparent 45%);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.grid-lines line {
|
||||
stroke: rgb(138 184 210 / 0.16);
|
||||
stroke-width: 0.45;
|
||||
}
|
||||
|
||||
.summary-area {
|
||||
fill: url(#summary-fill);
|
||||
}
|
||||
|
||||
.summary-line {
|
||||
fill: none;
|
||||
stroke: rgb(62 232 255 / 0.96);
|
||||
stroke-width: 1.35;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgb(62 232 255 / 0.22));
|
||||
}
|
||||
|
||||
.summary-dot {
|
||||
fill: rgb(133 255 68 / 0.98);
|
||||
filter: drop-shadow(0 0 6px rgb(133 255 68 / 0.3));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgb(155 186 204 / 0.76);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(180deg, rgb(2 7 11 / 0.06), rgb(2 7 11 / 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(173 206 227 / 0.9);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.metric-text {
|
||||
color: rgb(146 173 191 / 0.82);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.34rem;
|
||||
block-size: 0.34rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.tone-cyan {
|
||||
background: rgb(62 232 255);
|
||||
}
|
||||
|
||||
.dot.tone-lime {
|
||||
background: rgb(133 255 68);
|
||||
}
|
||||
|
||||
.dot.tone-orange {
|
||||
background: rgb(255 91 63);
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user