first commit

This commit is contained in:
lennlouisgeek
2026-03-30 02:59:56 +08:00
commit eec9927ae6
60 changed files with 15953 additions and 0 deletions

13
src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tauri + SvelteKit + Typescript App</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,774 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { flip } from "svelte/animate";
import { cubicIn, cubicOut } from "svelte/easing";
import { onMount } from "svelte";
import { fly } from "svelte/transition";
import ConfigPanel from "$lib/components/ConfigPanel.svelte";
import PressureMatrixViewer from "$lib/components/PressureMatrixViewer.svelte";
import SignalChart from "$lib/components/SignalChart.svelte";
import SummaryCurve from "$lib/components/SummaryCurve.svelte";
import type {
HudColorMapOption,
HudSignalPanel,
HudSummary,
PressureColorMapPreset,
StageStatusTone
} from "$lib/types/hud";
export let title = "";
export let hint = "";
export let statusText = "";
export let statusTone: StageStatusTone = "idle";
export let leftPanels: HudSignalPanel[] = [];
export let rightPanels: HudSignalPanel[] = [];
export let summary: HudSummary;
export let pressureMatrix: number[] | null = null;
export let showConfigPanel = false;
export let configPanelTitle = "";
export let configPanelHint = "";
export let matrixSizeLabel = "";
export let matrixRowsLabel = "";
export let matrixColsLabel = "";
export let rangeLabel = "";
export let rangeMinLabel = "";
export let rangeMaxLabel = "";
export let colorMapLabel = "";
export let resetConfigLabel = "";
export let applyLiveHint = "";
export let matrixRows = 12;
export let matrixCols = 7;
export let rangeMin = 0;
export let rangeMax = 5000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let colorMapOptions: HudColorMapOption[] = [];
export let replaySectionLabel = "";
export let replayPlayLabel = "";
export let replayPauseLabel = "";
export let replayStopLabel = "";
export let replaySpeedLabel = "";
export let replayProgressLabel = "";
export let replayHasData = false;
export let replayIsPlaying = false;
export let replaySpeed = 1;
export let replayProgress = 0;
export let replayFileName = "";
export let replayFrameInfo = "";
let stagePlaneEl: HTMLDivElement | undefined;
let topOverlayEl: HTMLDivElement | undefined;
let panelZoneEl: HTMLDivElement | undefined;
let leftStackEl: HTMLDivElement | undefined;
let rightStackEl: HTMLDivElement | undefined;
let panelZoneTopPx = 88;
let leftRailScale = 1;
let rightRailScale = 1;
let summarySide: "left" | "right" = "left";
let replaySide: "left" | "right" = "right";
const minRailScale = 0.2;
const dispatch = createEventDispatcher<{
configclose: void;
replaytoggle: void;
replaystop: void;
replayseek: number;
replayspeed: number;
replayclose: void;
}>();
$: summarySide = leftPanels.length <= rightPanels.length ? "left" : "right";
$: replaySide = summarySide === "left" ? "right" : "left";
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
function toPxNumber(rawValue: string): number {
const value = Number.parseFloat(rawValue);
return Number.isFinite(value) ? value : 0;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function calculateScale(zoneHeight: number, stackHeight: number): number {
if (zoneHeight <= 0 || stackHeight <= 0) {
return 1;
}
const rawScale = (zoneHeight - 6) / stackHeight;
return clamp(Math.round(rawScale * 1000) / 1000, minRailScale, 1);
}
function recomputePanelLayout(): void {
if (!stagePlaneEl || !topOverlayEl) {
return;
}
const planeRect = stagePlaneEl.getBoundingClientRect();
const overlayRect = topOverlayEl.getBoundingClientRect();
const overlayBottom = overlayRect.bottom - planeRect.top;
const upperTopLimit = Math.max(72, Math.round(stagePlaneEl.clientHeight * 0.34));
panelZoneTopPx = clamp(Math.round(overlayBottom + 8), 56, upperTopLimit);
const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0;
const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx);
leftRailScale = calculateScale(zoneHeight, leftStackEl?.scrollHeight ?? 0);
rightRailScale = calculateScale(zoneHeight, rightStackEl?.scrollHeight ?? 0);
}
function emitReplayToggle(): void {
dispatch("replaytoggle");
}
function emitReplayStop(): void {
dispatch("replaystop");
}
function emitReplaySeek(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
const normalized = Number(target.value) / 100;
dispatch("replayseek", Number.isFinite(normalized) ? normalized : 0);
}
function emitReplaySpeed(event: Event): void {
const target = event.currentTarget as HTMLSelectElement;
const nextSpeed = Number(target.value);
dispatch("replayspeed", Number.isFinite(nextSpeed) ? nextSpeed : 1);
}
function emitReplayClose(): void {
dispatch("replayclose");
}
onMount(() => {
recomputePanelLayout();
const resizeObserver = new ResizeObserver(() => {
recomputePanelLayout();
});
if (stagePlaneEl) {
resizeObserver.observe(stagePlaneEl);
}
if (topOverlayEl) {
resizeObserver.observe(topOverlayEl);
}
if (leftStackEl) {
resizeObserver.observe(leftStackEl);
}
if (rightStackEl) {
resizeObserver.observe(rightStackEl);
}
window.addEventListener("resize", recomputePanelLayout);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", recomputePanelLayout);
};
});
</script>
<section class="center-stage" aria-label="WebGL2 Area">
<article class="stage-shell">
<div
class="stage-canvas-plane"
bind:this={stagePlaneEl}
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
>
<div class="stage-top-overlay" bind:this={topOverlayEl}>
<div class="stage-meta">
<p class="meta-label">WebGL2 Stage</p>
<h2>{title}</h2>
<p class="meta-hint">{hint}</p>
</div>
<p class="runtime-status" class:is-ok={statusTone === "ok"} class:is-warn={statusTone === "warn"}>
{statusText}
</p>
</div>
<div class="canvas-wrap">
{#key `${matrixRows}x${matrixCols}`}
<PressureMatrixViewer
{pressureMatrix}
{matrixRows}
{matrixCols}
{rangeMin}
{rangeMax}
{colorMapPreset}
/>
{/key}
</div>
{#if showConfigPanel}
<div class="config-panel-wrap">
<ConfigPanel
bind:matrixRows
bind:matrixCols
bind:rangeMin
bind:rangeMax
title={configPanelTitle}
hint={configPanelHint}
{matrixSizeLabel}
{matrixRowsLabel}
{matrixColsLabel}
{rangeLabel}
{rangeMinLabel}
{rangeMaxLabel}
{colorMapLabel}
bind:colorMapPreset
{colorMapOptions}
resetLabel={resetConfigLabel}
{applyLiveHint}
on:close={() => dispatch("configclose")}
/>
</div>
{/if}
<div class="panel-zone" bind:this={panelZoneEl}>
<aside class="side-rail left-rail">
<div class="rail-stack" bind:this={leftStackEl}>
{#each leftPanels as panel, index (panel.id)}
<div
class="panel-motion-shell"
animate:flip={{ duration: 280 }}
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SignalChart {panel} panelIndex={index} />
</div>
{/each}
{#if summary.points.length > 0 && summarySide === "left"}
<div
class="panel-motion-shell"
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: -180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SummaryCurve {summary} side="left" panelIndex={leftPanels.length} />
</div>
{/if}
</div>
</aside>
<aside class="side-rail right-rail">
<div class="rail-stack" bind:this={rightStackEl}>
{#each rightPanels as panel, index (panel.id)}
<div
class="panel-motion-shell"
animate:flip={{ duration: 280 }}
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SignalChart {panel} panelIndex={index} />
</div>
{/each}
{#if summary.points.length > 0 && summarySide === "right"}
<div
class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
out:fly={{ x: 180, duration: 280, opacity: 0.06, easing: cubicIn }}
>
<SummaryCurve {summary} side="right" panelIndex={rightPanels.length} />
</div>
{/if}
</div>
</aside>
</div>
{#if replayHasData}
<aside class="replay-floating-panel" class:is-left={replaySide === "left"} class:is-right={replaySide === "right"}>
<div class="replay-panel-head">
<div class="replay-panel-title-group">
<p class="replay-panel-label">{replaySectionLabel}</p>
<p class="replay-panel-file" title={replayFileName}>{replayFileName}</p>
</div>
<div class="replay-panel-head-actions">
{#if replayFrameInfo}
<p class="replay-panel-frame">{replayFrameInfo}</p>
{/if}
<button type="button" class="replay-close-btn" aria-label="Close replay" on:click={emitReplayClose}>×</button>
</div>
</div>
<div class="replay-panel-controls">
<div class="replay-panel-actions">
<button type="button" class="replay-action-btn" on:click={emitReplayToggle}>{replayToggleButtonText}</button>
<button type="button" class="replay-action-btn is-stop" on:click={emitReplayStop}>{replayStopLabel}</button>
<label class="replay-speed-select">
<span>{replaySpeedLabel}</span>
<select value={replaySpeed} on:change={emitReplaySpeed}>
<option value={0.5}>0.5x</option>
<option value={1}>1x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</label>
</div>
<label class="replay-progress-slider">
<span>{replayProgressLabel}</span>
<input type="range" min="0" max="100" step="1" value={replayProgressPercent} on:input={emitReplaySeek} />
</label>
</div>
</aside>
{/if}
<div class="stage-bottom-overlay">
<slot />
</div>
</div>
</article>
</section>
<style>
.center-stage {
block-size: 100%;
min-height: 0;
}
.stage-shell {
position: relative;
block-size: 100%;
min-height: 0;
overflow: hidden;
border-radius: 0.72rem;
border: 1px solid rgb(101 133 152 / 0.2);
background:
linear-gradient(170deg, rgb(8 12 16 / 0.86) 0%, rgb(0 0 0 / 0.96) 58%, rgb(6 10 14 / 0.9) 100%),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.04), transparent 48%);
box-shadow:
inset 0 1px 0 rgb(175 216 240 / 0.08),
inset 0 -36px 72px rgb(0 0 0 / 0.4);
}
.stage-canvas-plane {
--rail-width: clamp(17.5rem, 23vw, 21.5rem);
--rail-edge-inset: clamp(0.35rem, 1vw, 0.9rem);
--safe-gap: clamp(0.35rem, 0.9vw, 0.85rem);
--panel-zone-top: clamp(6.4rem, 11.8vh, 8rem);
--panel-zone-bottom: clamp(1.8rem, 3.6vh, 2.8rem);
--rail-gap: clamp(0.55rem, 1.4vh, 1rem);
--bottom-inset: clamp(0.35rem, 1.2vw, 0.9rem);
position: relative;
inline-size: 100%;
block-size: 100%;
}
.stage-top-overlay {
position: absolute;
top: clamp(0.55rem, 1.1vw, 0.9rem);
left: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
right: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.7rem;
z-index: 7;
pointer-events: none;
}
.stage-meta {
min-width: 0;
max-inline-size: min(22rem, 62%);
padding: 0.3rem 0.5rem 0.35rem;
border: 1px solid rgb(112 146 166 / 0.2);
border-radius: 0.45rem;
background: rgb(2 8 12 / 0.45);
backdrop-filter: blur(2px);
}
.meta-label {
margin: 0;
font-size: 0.56rem;
color: rgb(148 171 189 / 0.8);
text-transform: uppercase;
letter-spacing: 0.1em;
}
h2 {
margin: 0.08rem 0 0;
font-size: clamp(0.75rem, 1.1vw, 0.92rem);
color: rgb(222 241 255 / 0.96);
letter-spacing: 0.03em;
font-weight: 500;
line-height: 1.2;
}
.meta-hint {
margin: 0.09rem 0 0;
font-size: 0.62rem;
color: rgb(142 165 183 / 0.76);
line-height: 1.15;
}
.runtime-status {
margin: 0;
align-self: center;
border: 1px solid rgb(95 128 149 / 0.35);
border-radius: 999px;
padding: 0.3rem 0.66rem;
font-size: 0.66rem;
letter-spacing: 0.08em;
color: rgb(150 174 194 / 0.9);
text-transform: uppercase;
white-space: nowrap;
background: rgb(3 10 15 / 0.62);
}
.runtime-status.is-ok {
color: rgb(204 248 184 / 0.94);
}
.runtime-status.is-warn {
color: rgb(255 205 188 / 0.92);
}
.canvas-wrap {
position: absolute;
inset: 0;
min-height: 0;
overflow: hidden;
}
.config-panel-wrap {
position: absolute;
top: clamp(5rem, 11.4vh, 6.8rem);
right: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
z-index: 8;
pointer-events: auto;
max-inline-size: min(24rem, 40vw);
}
.panel-zone {
position: absolute;
top: var(--panel-zone-top-dyn, var(--panel-zone-top));
right: 0;
bottom: var(--panel-zone-bottom);
left: 0;
z-index: 6;
pointer-events: none;
}
.side-rail {
position: absolute;
top: 0;
bottom: 0;
inline-size: var(--rail-width);
min-height: 0;
pointer-events: none;
display: flex;
align-items: flex-end;
}
.left-rail {
left: var(--rail-edge-inset);
}
.right-rail {
right: var(--rail-edge-inset);
}
.rail-stack {
display: flex;
flex-direction: column;
gap: var(--rail-gap);
transform: scale(var(--rail-scale));
pointer-events: auto;
}
.left-rail .rail-stack {
align-items: flex-start;
transform: scale(var(--rail-scale-left, 1));
transform-origin: left bottom;
}
.right-rail .rail-stack {
align-items: flex-end;
transform: scale(var(--rail-scale-right, 1));
transform-origin: right bottom;
}
.left-rail :global(.signal-panel),
.right-rail :global(.signal-panel) {
pointer-events: auto;
}
.panel-motion-shell {
inline-size: 100%;
display: flex;
}
.left-rail .panel-motion-shell {
justify-content: flex-start;
}
.right-rail .panel-motion-shell {
justify-content: flex-end;
}
.replay-floating-panel {
position: absolute;
top: calc(var(--panel-zone-top-dyn, var(--panel-zone-top)) + 0.2rem);
inline-size: min(var(--rail-width), 23.5rem);
z-index: 8;
pointer-events: auto;
display: grid;
gap: 0.52rem;
border: 1px solid rgb(95 136 159 / 0.34);
border-radius: 0.66rem;
padding: 0.66rem 0.72rem;
background:
linear-gradient(180deg, rgb(8 14 19 / 0.86), rgb(4 8 12 / 0.8)),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.07), transparent 56%);
box-shadow:
inset 0 1px 0 rgb(183 218 239 / 0.08),
0 0 18px rgb(62 232 255 / 0.1);
}
.replay-floating-panel.is-right {
right: var(--rail-edge-inset);
}
.replay-floating-panel.is-left {
left: var(--rail-edge-inset);
}
.replay-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.replay-panel-head-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.replay-panel-title-group {
min-inline-size: 0;
display: grid;
gap: 0.24rem;
}
.replay-panel-label,
.replay-panel-file,
.replay-panel-frame {
margin: 0;
line-height: 1.12;
}
.replay-panel-label {
font-size: 0.58rem;
letter-spacing: 0.11em;
text-transform: uppercase;
color: rgb(152 185 206 / 0.86);
}
.replay-panel-file {
font-size: 0.73rem;
letter-spacing: 0.03em;
color: rgb(221 241 255 / 0.94);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.replay-panel-frame {
border: 1px solid rgb(133 255 68 / 0.36);
border-radius: 999px;
padding: 0.16rem 0.52rem;
background: rgb(17 28 15 / 0.64);
color: rgb(204 255 178 / 0.94);
font-size: 0.67rem;
letter-spacing: 0.07em;
white-space: nowrap;
}
.replay-close-btn {
inline-size: 1.82rem;
block-size: 1.82rem;
border: 1px solid rgb(255 98 76 / 0.44);
border-radius: 0.32rem;
background: rgb(24 10 12 / 0.88);
color: rgb(255 210 203 / 0.96);
font-size: 1rem;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
color 180ms ease;
}
.replay-close-btn:hover {
border-color: rgb(255 132 115 / 0.66);
color: rgb(255 234 228 / 0.98);
box-shadow: 0 0 12px rgb(255 91 63 / 0.2);
}
.replay-panel-controls {
display: grid;
gap: 0.46rem;
}
.replay-panel-actions {
display: flex;
align-items: center;
gap: 0.48rem;
flex-wrap: wrap;
}
.replay-action-btn {
min-block-size: 1.82rem;
border: 1px solid rgb(62 232 255 / 0.36);
border-radius: 999px;
padding: 0.2rem 0.66rem;
background: rgb(8 19 25 / 0.9);
color: rgb(225 246 255 / 0.96);
font-size: 0.7rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease;
}
.replay-action-btn:hover {
border-color: rgb(116 245 255 / 0.58);
box-shadow: 0 0 10px rgb(62 232 255 / 0.14);
}
.replay-action-btn.is-stop {
border-color: rgb(255 91 63 / 0.44);
color: rgb(255 223 214 / 0.94);
background: rgb(27 12 10 / 0.86);
}
.replay-action-btn.is-stop:hover {
border-color: rgb(255 124 101 / 0.64);
box-shadow: 0 0 10px rgb(255 91 63 / 0.18);
}
.replay-speed-select,
.replay-progress-slider {
display: inline-flex;
align-items: center;
gap: 0.36rem;
min-block-size: 1.92rem;
border: 1px solid rgb(95 132 158 / 0.32);
border-radius: 999px;
padding: 0.16rem 0.2rem 0.16rem 0.48rem;
background: rgb(8 15 21 / 0.78);
}
.replay-speed-select span,
.replay-progress-slider span {
color: rgb(154 176 194 / 0.84);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.replay-speed-select select {
border: 1px solid rgb(95 132 158 / 0.34);
border-radius: 999px;
padding: 0.22rem 0.48rem;
background: rgb(4 11 16 / 0.88);
color: rgb(216 235 248 / 0.96);
font-size: 0.72rem;
letter-spacing: 0.05em;
outline: none;
}
.replay-progress-slider {
inline-size: 100%;
}
.replay-progress-slider input {
inline-size: 100%;
accent-color: rgb(133 255 68 / 0.92);
}
.stage-bottom-overlay {
position: absolute;
left: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
right: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
bottom: var(--bottom-inset);
z-index: 7;
pointer-events: none;
}
@media (max-width: 1180px) {
.stage-canvas-plane {
--rail-width: clamp(14.2rem, 28vw, 16.4rem);
--rail-edge-inset: clamp(0.25rem, 0.7vw, 0.55rem);
--safe-gap: clamp(0.2rem, 0.75vw, 0.45rem);
--panel-zone-top: clamp(6rem, 11.2vh, 7.2rem);
--panel-zone-bottom: clamp(1.1rem, 2.7vh, 2rem);
--rail-gap: clamp(0.45rem, 1vh, 0.75rem);
}
.config-panel-wrap {
max-inline-size: min(21rem, 46vw);
}
.replay-floating-panel {
inline-size: min(var(--rail-width), 20.8rem);
}
}
@media (max-height: 900px) {
.stage-canvas-plane {
--panel-zone-top: clamp(5.6rem, 10vh, 6.8rem);
--panel-zone-bottom: clamp(0.9rem, 1.8vh, 1.4rem);
}
.config-panel-wrap {
top: clamp(4.7rem, 10vh, 6rem);
}
}
@media (max-height: 760px) {
.stage-canvas-plane {
--panel-zone-top: clamp(5.1rem, 9.2vh, 6.2rem);
--panel-zone-bottom: clamp(0.45rem, 1vh, 0.85rem);
--rail-gap: clamp(0.35rem, 0.8vh, 0.62rem);
}
.config-panel-wrap {
top: clamp(4.4rem, 9vh, 5.5rem);
}
.replay-floating-panel {
top: calc(var(--panel-zone-top-dyn, var(--panel-zone-top)) + 0.1rem);
padding: 0.58rem 0.64rem;
gap: 0.44rem;
}
}
@media (max-width: 920px) {
.config-panel-wrap {
left: calc(var(--rail-width) + var(--safe-gap) + var(--rail-edge-inset));
right: auto;
max-inline-size: min(21rem, 54vw);
}
.replay-floating-panel {
left: calc(var(--rail-edge-inset) + 0.1rem);
right: calc(var(--rail-edge-inset) + 0.1rem);
inline-size: auto;
}
}
</style>

View File

@@ -0,0 +1,460 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { HudColorMapOption, PressureColorMapPreset } from "$lib/types/hud";
export let title = "";
export let hint = "";
export let matrixSizeLabel = "";
export let matrixRowsLabel = "";
export let matrixColsLabel = "";
export let rangeLabel = "";
export let rangeMinLabel = "";
export let rangeMaxLabel = "";
export let colorMapLabel = "";
export let resetLabel = "";
export let applyLiveHint = "";
export let matrixRows = 12;
export let matrixCols = 7;
export let rangeMin = 0;
export let rangeMax = 5000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let colorMapOptions: HudColorMapOption[] = [];
const dispatch = createEventDispatcher<{
close: void;
}>();
const presetSizes = [12, 24, 32, 48, 64];
const gridMin = 1;
const gridMax = 128;
const gridStep = 1;
const rangeFloor = -9999;
const rangeCeiling = 99999;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeGridValue(value: number): number {
const numericValue = Number.isFinite(value) ? value : 12;
return clamp(Math.round(numericValue), gridMin, gridMax);
}
function normalizeRangeValue(value: number): number {
return clamp(Math.round(Number.isFinite(value) ? value : 0), rangeFloor, rangeCeiling);
}
function handleRowsInput(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
matrixRows = normalizeGridValue(target.valueAsNumber);
}
function handleColsInput(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
matrixCols = normalizeGridValue(target.valueAsNumber);
}
function handleRangeMinInput(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
rangeMin = normalizeRangeValue(target.valueAsNumber);
if (rangeMin >= rangeMax) {
rangeMax = rangeMin + 1;
}
}
function handleRangeMaxInput(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
rangeMax = normalizeRangeValue(target.valueAsNumber);
if (rangeMax <= rangeMin) {
rangeMin = rangeMax - 1;
}
}
function applyPreset(size: number): void {
matrixRows = size;
matrixCols = size;
}
function applyColorMapPreset(id: PressureColorMapPreset): void {
colorMapPreset = id;
}
function resetDefaults(): void {
matrixRows = 12;
matrixCols = 7;
rangeMin = 0;
rangeMax = 5000;
colorMapPreset = "emerald";
}
$: selectedColorMap = colorMapOptions.find((option) => option.id === colorMapPreset) ?? colorMapOptions[0];
$: {
const nextRows = normalizeGridValue(matrixRows);
if (nextRows !== matrixRows) {
matrixRows = nextRows;
}
}
$: {
const nextCols = normalizeGridValue(matrixCols);
if (nextCols !== matrixCols) {
matrixCols = nextCols;
}
}
$: {
const nextRangeMin = normalizeRangeValue(rangeMin);
if (nextRangeMin !== rangeMin) {
rangeMin = nextRangeMin;
}
}
$: {
const nextRangeMax = Math.max(normalizeRangeValue(rangeMax), rangeMin + 1);
if (nextRangeMax !== rangeMax) {
rangeMax = nextRangeMax;
}
}
</script>
<section class="config-panel" aria-label={title}>
<header class="config-head">
<div class="config-copy">
<p class="config-label">Stage Config</p>
<h3>{title}</h3>
<p class="config-hint">{hint}</p>
</div>
<button type="button" class="close-btn" on:click={() => dispatch("close")} aria-label="Close config panel">
<span></span>
<span></span>
</button>
</header>
<div class="config-section">
<div class="section-head">
<p class="section-title">{matrixSizeLabel}</p>
<p class="section-note">{matrixRows} x {matrixCols}</p>
</div>
<div class="preset-row" role="group" aria-label={matrixSizeLabel}>
{#each presetSizes as size}
<button
type="button"
class="preset-btn"
class:is-active={matrixRows === size && matrixCols === size}
on:click={() => applyPreset(size)}
>
{size}x{size}
</button>
{/each}
</div>
<div class="field-grid">
<label class="field-card">
<span class="field-label">{matrixRowsLabel}</span>
<input type="number" min={gridMin} max={gridMax} step={gridStep} value={matrixRows} on:input={handleRowsInput} />
</label>
<label class="field-card">
<span class="field-label">{matrixColsLabel}</span>
<input type="number" min={gridMin} max={gridMax} step={gridStep} value={matrixCols} on:input={handleColsInput} />
</label>
</div>
</div>
<div class="config-section">
<div class="section-head">
<p class="section-title">{rangeLabel}</p>
<p class="section-note">{rangeMin} - {rangeMax}</p>
</div>
<div class="field-grid">
<label class="field-card">
<span class="field-label">{rangeMinLabel}</span>
<input type="number" min={rangeFloor} max={rangeCeiling} step="100" value={rangeMin} on:input={handleRangeMinInput} />
</label>
<label class="field-card">
<span class="field-label">{rangeMaxLabel}</span>
<input type="number" min={rangeFloor} max={rangeCeiling} step="100" value={rangeMax} on:input={handleRangeMaxInput} />
</label>
</div>
</div>
<div class="config-section">
<div class="section-head">
<p class="section-title">{colorMapLabel}</p>
<p class="section-note">{selectedColorMap?.label ?? colorMapPreset}</p>
</div>
<div class="palette-row" role="group" aria-label={colorMapLabel}>
{#each colorMapOptions as option}
<button
type="button"
class="palette-btn"
class:is-active={colorMapPreset === option.id}
on:click={() => applyColorMapPreset(option.id)}
>
<span
class="palette-preview"
style={`--palette-stop-0: ${option.previewStops[0]}; --palette-stop-1: ${option.previewStops[1]}; --palette-stop-2: ${option.previewStops[2]};`}
aria-hidden="true"
></span>
<span class="palette-name">{option.label}</span>
</button>
{/each}
</div>
</div>
<footer class="config-foot">
<p class="live-note">{applyLiveHint}</p>
<button type="button" class="reset-btn" on:click={resetDefaults}>{resetLabel}</button>
</footer>
</section>
<style>
.config-panel {
display: grid;
gap: 0.9rem;
inline-size: min(23rem, 100%);
padding: 0.92rem 0.96rem 1rem;
border: 1px solid rgb(88 132 116 / 0.3);
border-radius: 0.82rem;
background:
linear-gradient(180deg, rgb(6 18 14 / 0.92), rgb(4 10 9 / 0.88)),
radial-gradient(circle at 100% 0, rgb(97 146 255 / 0.07), transparent 38%);
box-shadow:
inset 0 1px 0 rgb(184 236 206 / 0.08),
0 18px 46px rgb(0 0 0 / 0.28);
backdrop-filter: blur(10px);
}
.config-head {
display: flex;
justify-content: space-between;
gap: 0.9rem;
align-items: flex-start;
}
.config-copy {
min-width: 0;
}
.config-label,
.section-title,
.field-label,
.live-note {
margin: 0;
color: rgb(157 206 181 / 0.8);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h3 {
margin: 0.12rem 0 0;
color: rgb(237 248 241 / 0.98);
font-size: 1rem;
line-height: 1.2;
font-weight: 600;
}
.config-hint,
.section-note {
margin: 0.14rem 0 0;
color: rgb(142 182 164 / 0.78);
font-size: 0.7rem;
line-height: 1.25;
}
.close-btn {
position: relative;
inline-size: 2rem;
block-size: 2rem;
border: 1px solid rgb(82 122 106 / 0.32);
border-radius: 999px;
background: rgb(4 12 9 / 0.72);
cursor: pointer;
flex: 0 0 auto;
}
.close-btn span {
position: absolute;
top: 50%;
left: 50%;
inline-size: 0.8rem;
block-size: 1px;
background: rgb(182 210 195 / 0.9);
transform-origin: center;
}
.close-btn span:first-child {
transform: translate(-50%, -50%) rotate(45deg);
}
.close-btn span:last-child {
transform: translate(-50%, -50%) rotate(-45deg);
}
.config-section {
display: grid;
gap: 0.62rem;
padding: 0.76rem 0.8rem;
border: 1px solid rgb(72 116 96 / 0.22);
border-radius: 0.72rem;
background: linear-gradient(180deg, rgb(7 15 12 / 0.76), rgb(5 10 8 / 0.64));
}
.section-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
}
.palette-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.48rem;
}
.preset-btn,
.reset-btn,
.palette-btn {
border: 1px solid rgb(80 126 105 / 0.28);
border-radius: 999px;
padding: 0.38rem 0.72rem;
background: rgb(8 19 15 / 0.76);
color: rgb(191 219 206 / 0.92);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
color 160ms ease,
background-color 160ms ease;
}
.preset-btn.is-active {
border-color: rgb(98 201 149 / 0.48);
background: linear-gradient(180deg, rgb(18 54 37 / 0.96), rgb(10 32 23 / 0.92));
color: rgb(233 247 240 / 0.98);
box-shadow: inset 0 1px 0 rgb(198 246 222 / 0.14), 0 0 16px rgb(63 184 120 / 0.14);
}
.palette-btn {
display: grid;
gap: 0.34rem;
min-height: 4rem;
padding: 0.52rem 0.56rem 0.58rem;
border-radius: 0.74rem;
text-align: left;
}
.palette-btn.is-active {
border-color: rgb(98 201 149 / 0.48);
background: linear-gradient(180deg, rgb(18 54 37 / 0.96), rgb(10 32 23 / 0.92));
color: rgb(233 247 240 / 0.98);
box-shadow: inset 0 1px 0 rgb(198 246 222 / 0.14), 0 0 16px rgb(63 184 120 / 0.14);
}
.palette-preview {
display: block;
inline-size: 100%;
block-size: 0.74rem;
border-radius: 999px;
border: 1px solid rgb(255 255 255 / 0.08);
background:
linear-gradient(
90deg,
var(--palette-stop-0) 0%,
var(--palette-stop-1) 52%,
var(--palette-stop-2) 100%
),
linear-gradient(180deg, rgb(255 255 255 / 0.08), transparent 55%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.1),
0 0 12px rgb(0 0 0 / 0.14);
}
.palette-name {
color: inherit;
font-size: 0.74rem;
line-height: 1.1;
letter-spacing: 0.04em;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.58rem;
}
.field-card {
display: grid;
gap: 0.38rem;
padding: 0.58rem 0.64rem 0.66rem;
border: 1px solid rgb(68 106 89 / 0.26);
border-radius: 0.58rem;
background: linear-gradient(180deg, rgb(6 14 11 / 0.86), rgb(3 8 6 / 0.82));
}
.field-card input {
inline-size: 100%;
border: 1px solid rgb(82 131 109 / 0.28);
border-radius: 0.46rem;
padding: 0.55rem 0.62rem;
background: rgb(7 16 12 / 0.92);
color: rgb(238 246 241 / 0.98);
font: inherit;
font-size: 0.86rem;
outline: none;
}
.field-card input:focus {
border-color: rgb(97 201 147 / 0.54);
box-shadow: 0 0 0 1px rgb(97 201 147 / 0.24);
}
.config-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.live-note {
color: rgb(142 182 164 / 0.8);
}
.reset-btn {
background: linear-gradient(180deg, rgb(10 21 17 / 0.88), rgb(6 12 10 / 0.84));
white-space: nowrap;
}
@media (max-width: 960px) {
.config-panel {
inline-size: min(20rem, 100%);
padding: 0.86rem 0.88rem 0.94rem;
}
.field-grid {
grid-template-columns: 1fr;
}
.palette-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,970 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type {
ConnectionState,
HudConfigLink,
HudNoticeTone,
LocaleCode,
WindowControlAction
} from "$lib/types/hud";
export let appName = "";
export let suiteName = "";
export let controlAreaLabel = "";
export let locale: LocaleCode = "zh-CN";
export let connectionState: ConnectionState = "offline";
export let connectionLabel = "";
export let connectedLabel = "";
export let connectingLabel = "";
export let disconnectedLabel = "";
export let deviceLabel = "";
export let deviceValue = "";
export let sampleRateLabel = "";
export let sampleRateValue = "";
export let channelsLabel = "";
export let channelsValue = "";
export let serialPortLabel = "";
export let serialPortValue = "";
export let serialPortOptions: string[] = [];
export let refreshPortsLabel = "";
export let configLinksLabel = "";
export let configLinks: HudConfigLink[] = [];
export let connectActionLabel = "";
export let disconnectActionLabel = "";
export let exportActionLabel = "";
export let exportingActionLabel = "";
export let importActionLabel = "";
export let connectionNotice = "";
export let connectionNoticeTone: HudNoticeTone = "info";
export let isRefreshingPorts = false;
export let isConnectDisabled = false;
export let isExporting = false;
export let isExportDisabled = false;
export let isWindowMaximized = false;
let csvInputEl: HTMLInputElement | undefined;
const dispatch = createEventDispatcher<{
windowcontrol: WindowControlAction;
localechange: LocaleCode;
configlink: string;
portchange: string;
serialrefresh: void;
serialconnect: string;
serialexport: void;
csvimport: File;
}>();
const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = {
online: "ok",
connecting: "warn",
offline: "idle"
};
$: connectionTone = connectionToneByState[connectionState];
$: connectionText =
connectionState === "online"
? connectedLabel
: connectionState === "connecting"
? connectingLabel
: disconnectedLabel;
$: connectButtonText =
connectionState === "online"
? disconnectActionLabel
: connectionState === "connecting"
? connectingLabel
: connectActionLabel;
$: exportButtonText = isExporting ? exportingActionLabel || exportActionLabel : exportActionLabel;
$: resolvedSerialPortOptions =
serialPortOptions.length > 0 ? serialPortOptions : serialPortValue ? [serialPortValue] : [];
function emitWindowControl(action: WindowControlAction): void {
dispatch("windowcontrol", action);
}
function switchLocale(nextLocale: LocaleCode): void {
dispatch("localechange", nextLocale);
}
function emitConfigLink(linkId: string): void {
dispatch("configlink", linkId);
}
function emitPortChange(event: Event): void {
const target = event.currentTarget as HTMLSelectElement;
dispatch("portchange", target.value);
}
function emitSerialConnect(): void {
dispatch("serialconnect", serialPortValue);
}
function emitSerialRefresh(): void {
dispatch("serialrefresh");
}
function emitSerialExport(): void {
dispatch("serialexport");
}
function openCsvPicker(): void {
csvInputEl?.click();
}
function emitCsvImport(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
const file = target.files?.[0];
if (file) {
dispatch("csvimport", file);
}
target.value = "";
}
</script>
<section class="hud-panel" aria-label={controlAreaLabel}>
<div class="title-bar" role="group" aria-label="Title Bar" data-tauri-drag-region>
<div class="title-cluster" data-tauri-drag-region>
<span class="title-pulse" aria-hidden="true"></span>
<strong class="app-name">{appName}</strong>
<span class="suite-tag">{suiteName}</span>
</div>
<div class="window-controls" aria-label="Window Controls">
<button
type="button"
class="window-btn"
on:click={() => emitWindowControl("minimize")}
aria-label="Minimize window"
>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 6h8"></path>
</svg>
</button>
<button
type="button"
class="window-btn"
class:is-maximized={isWindowMaximized}
on:click={() => emitWindowControl("toggle-maximize")}
aria-label="Toggle maximize window"
>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.6 2.6h6.8v6.8h-6.8z"></path>
</svg>
</button>
<button
type="button"
class="window-btn is-close"
on:click={() => emitWindowControl("close")}
aria-label="Close window"
>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.6 2.6l6.8 6.8"></path>
<path d="M9.4 2.6l-6.8 6.8"></path>
</svg>
</button>
</div>
</div>
<div class="control-bar">
<div class="control-main-row">
<section class="config-links" aria-label={configLinksLabel}>
{#each configLinks as link (link.id)}
<button
type="button"
class="config-link tone-{link.tone ?? 'neutral'}"
class:is-active={Boolean(link.active)}
on:click={() => emitConfigLink(link.id)}
>
<span class="config-indicator" aria-hidden="true"></span>
<span class="config-label">{link.label}</span>
</button>
{/each}
</section>
<section class="state-card" aria-label={connectionLabel}>
<span class="state-dot" class:ok={connectionTone === "ok"} class:warn={connectionTone === "warn"}></span>
<span class="state-label">{connectionLabel}</span>
<strong class="state-value">{connectionText}</strong>
</section>
<label class="serial-select" aria-label={serialPortLabel}>
<span class="serial-tag">{serialPortLabel}</span>
<span class="serial-select-wrap">
<select
class="serial-select-input"
value={serialPortValue}
disabled={connectionState !== "offline" || isRefreshingPorts}
on:change={emitPortChange}
>
{#each resolvedSerialPortOptions as option}
<option value={option}>{option}</option>
{/each}
</select>
<span class="serial-select-caret" aria-hidden="true"></span>
</span>
</label>
<button
type="button"
class="refresh-btn"
disabled={isRefreshingPorts || connectionState !== "offline"}
on:click={emitSerialRefresh}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M13 4.8V1.8"></path>
<path d="M13 1.8H10"></path>
<path d="M13 1.8a5.7 5.7 0 0 0-9.7 3.4"></path>
<path d="M3 11.2v3"></path>
<path d="M3 14.2h3"></path>
<path d="M3 14.2a5.7 5.7 0 0 0 9.7-3.4"></path>
</svg>
<span>{isRefreshingPorts ? `${refreshPortsLabel}...` : refreshPortsLabel}</span>
</button>
<button
type="button"
class="connect-btn"
class:is-busy={connectionState === "connecting"}
class:is-connected={connectionState === "online"}
disabled={isConnectDisabled}
on:click={emitSerialConnect}
>
<span class="connect-btn-indicator" aria-hidden="true"></span>
<span>{connectButtonText}</span>
</button>
<button
type="button"
class="export-btn"
disabled={isExportDisabled || isExporting}
on:click={emitSerialExport}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 2.2v7.2"></path>
<path d="M5.4 7.8L8 10.5l2.6-2.7"></path>
<path d="M3.1 12.3h9.8"></path>
</svg>
<span>{exportButtonText}</span>
</button>
<button type="button" class="import-btn" on:click={openCsvPicker}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 10.8V3.6"></path>
<path d="M5.4 6.3L8 3.6l2.6 2.7"></path>
<path d="M3.1 12.6h9.8"></path>
</svg>
<span>{importActionLabel}</span>
</button>
<input
bind:this={csvInputEl}
class="hidden-input"
type="file"
accept=".csv,text/csv"
on:change={emitCsvImport}
/>
<section class="locale-switch" aria-label="Language">
<button
type="button"
class="locale-btn"
class:is-active={locale === "zh-CN"}
on:click={() => switchLocale("zh-CN")}
>
中文
</button>
<button
type="button"
class="locale-btn"
class:is-active={locale === "en-US"}
on:click={() => switchLocale("en-US")}
>
English
</button>
</section>
</div>
{#if connectionNotice}
<p class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
{connectionNotice}
</p>
{/if}
<section class="info-grid">
<article class="info-cell">
<p class="meta-label">{deviceLabel}</p>
<p class="meta-value">{deviceValue}</p>
</article>
<article class="info-cell">
<p class="meta-label">{sampleRateLabel}</p>
<p class="meta-value">{sampleRateValue}</p>
</article>
<article class="info-cell">
<p class="meta-label">{channelsLabel}</p>
<p class="meta-value">{channelsValue}</p>
</article>
</section>
</div>
</section>
<style>
.hud-panel {
display: grid;
grid-template-rows: auto auto;
gap: clamp(0.5rem, 1.2vw, 0.85rem);
}
.title-bar {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgb(108 143 166 / 0.22);
padding: 0.05rem 0.1rem 0.55rem 0.1rem;
background: linear-gradient(180deg, rgb(15 22 28 / 0.32), transparent);
}
.title-cluster {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.title-pulse {
inline-size: 0.52rem;
block-size: 0.52rem;
border-radius: 50%;
background: rgb(133 255 68 / 0.95);
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.14);
}
.app-name {
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.07em;
color: #f0fbff;
text-transform: uppercase;
}
.suite-tag {
font-size: 0.72rem;
color: var(--hud-text-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.window-controls {
display: inline-flex;
align-items: center;
gap: 0.38rem;
}
.window-btn {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.8rem;
block-size: 1.52rem;
border: 1px solid rgb(82 120 146 / 0.36);
border-radius: 0.34rem;
color: rgb(179 245 255 / 0.92);
background: rgb(8 12 16 / 0.82);
cursor: pointer;
transition:
background-color 200ms ease,
border-color 200ms ease,
color 200ms ease;
}
.window-btn svg {
inline-size: 0.8rem;
block-size: 0.8rem;
stroke: currentColor;
stroke-width: 1.4;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.window-btn:hover {
border-color: rgb(62 232 255 / 0.42);
background: rgb(14 20 26 / 0.9);
color: #f3fdff;
}
.window-btn.is-maximized {
border-color: rgb(133 255 68 / 0.5);
color: rgb(211 255 190 / 0.92);
}
.window-btn.is-close:hover {
border-color: rgb(255 91 63 / 0.62);
background: rgb(27 11 10 / 0.9);
color: rgb(255 200 188 / 0.96);
}
.control-bar {
display: grid;
gap: 0.45rem;
padding: 0 0.1rem;
background: linear-gradient(90deg, rgb(62 232 255 / 0.02), transparent 45%, rgb(133 255 68 / 0.015));
}
.control-main-row {
display: flex;
align-items: center;
gap: 0.58rem;
flex-wrap: wrap;
}
.state-card {
display: inline-flex;
align-items: center;
gap: 0.42rem;
min-block-size: 2rem;
border: 1px solid rgb(95 132 158 / 0.3);
border-radius: 999px;
padding: 0.2rem 0.62rem 0.2rem 0.36rem;
background: rgb(10 16 20 / 0.68);
}
.state-dot {
inline-size: 0.55rem;
block-size: 0.55rem;
border-radius: 50%;
background: rgb(143 165 186 / 0.92);
box-shadow: 0 0 0 2px rgb(143 165 186 / 0.14);
}
.state-dot.ok {
background: var(--hud-lime);
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.16);
}
.state-dot.warn {
background: var(--hud-orange);
box-shadow: 0 0 0 2px rgb(255 91 63 / 0.16);
}
.state-label {
color: rgb(154 176 194 / 0.84);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
}
.state-value {
color: #ecf8ff;
font-size: 0.92rem;
letter-spacing: 0.02em;
font-weight: 600;
line-height: 1;
}
.serial-select {
display: inline-flex;
align-items: center;
gap: 0.38rem;
min-block-size: 2rem;
border: 1px solid rgb(95 132 158 / 0.3);
border-radius: 999px;
padding: 0.18rem 0.2rem 0.18rem 0.56rem;
background: rgb(10 16 20 / 0.7);
min-inline-size: 0;
}
.serial-select-wrap {
position: relative;
display: inline-block;
min-inline-size: 0;
}
.serial-tag {
color: rgb(154 176 194 / 0.84);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
white-space: nowrap;
}
.serial-select-input {
appearance: none;
inline-size: 100%;
min-inline-size: 7rem;
border: 1px solid rgb(95 132 158 / 0.32);
border-radius: 999px;
padding: 0.3rem 1.5rem 0.3rem 0.6rem;
background: rgb(4 11 16 / 0.84);
color: #d5ebfb;
font-size: 0.84rem;
letter-spacing: 0.06em;
text-transform: uppercase;
outline: none;
transition:
border-color 180ms ease,
box-shadow 180ms ease;
}
.serial-select-input:hover {
border-color: rgb(62 232 255 / 0.36);
}
.serial-select-input:focus-visible {
border-color: rgb(62 232 255 / 0.5);
box-shadow: 0 0 0 2px rgb(62 232 255 / 0.14);
}
.serial-select-caret {
position: absolute;
inset-inline-end: 0.68rem;
inset-block-start: 50%;
inline-size: 0.42rem;
block-size: 0.42rem;
border-right: 1px solid rgb(153 189 214 / 0.82);
border-bottom: 1px solid rgb(153 189 214 / 0.82);
transform: translateY(-64%) rotate(45deg);
pointer-events: none;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.36rem;
min-block-size: 2rem;
border: 1px solid rgb(95 132 158 / 0.34);
border-radius: 999px;
padding: 0.24rem 0.64rem;
background:
linear-gradient(180deg, rgb(11 18 24 / 0.92), rgb(7 12 17 / 0.88)),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.1), transparent 58%);
color: rgb(214 236 248 / 0.96);
font-size: 0.74rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
box-shadow 200ms ease,
opacity 180ms ease;
}
.refresh-btn svg {
inline-size: 0.84rem;
block-size: 0.84rem;
stroke: currentColor;
stroke-width: 1.35;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.refresh-btn:hover:not(:disabled) {
border-color: rgb(62 232 255 / 0.44);
box-shadow:
inset 0 0 0 1px rgb(167 218 252 / 0.07),
0 0 10px rgb(62 232 255 / 0.1);
}
.refresh-btn:disabled {
opacity: 0.72;
cursor: default;
}
.connect-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
min-block-size: 2rem;
border: 1px solid rgb(133 255 68 / 0.4);
border-radius: 999px;
padding: 0.24rem 0.76rem;
background:
linear-gradient(180deg, rgb(24 33 22 / 0.96), rgb(12 19 12 / 0.92)),
radial-gradient(circle at 50% 0, rgb(133 255 68 / 0.12), transparent 58%);
color: #f2ffe8;
font-size: 0.78rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
box-shadow 200ms ease,
opacity 180ms ease;
}
.connect-btn:hover:not(:disabled) {
border-color: rgb(170 255 121 / 0.62);
box-shadow:
inset 0 0 0 1px rgb(231 255 214 / 0.08),
0 0 12px rgb(133 255 68 / 0.14);
}
.connect-btn:disabled {
opacity: 0.72;
cursor: default;
}
.connect-btn.is-busy {
border-color: rgb(255 91 63 / 0.48);
background:
linear-gradient(180deg, rgb(38 18 15 / 0.96), rgb(23 10 10 / 0.92)),
radial-gradient(circle at 50% 0, rgb(255 91 63 / 0.12), transparent 58%);
color: rgb(255 223 217 / 0.96);
}
.connect-btn.is-connected {
border-color: rgb(62 232 255 / 0.46);
background:
linear-gradient(180deg, rgb(10 28 32 / 0.96), rgb(6 15 18 / 0.92)),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.14), transparent 58%);
color: rgb(227 251 255 / 0.98);
}
.connect-btn-indicator {
inline-size: 0.4rem;
block-size: 0.4rem;
border-radius: 999px;
background: var(--hud-lime);
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.15);
}
.connect-btn.is-busy .connect-btn-indicator {
background: var(--hud-orange);
box-shadow: 0 0 0 2px rgb(255 91 63 / 0.16);
}
.connect-btn.is-connected .connect-btn-indicator {
background: var(--hud-cyan);
box-shadow: 0 0 0 2px rgb(62 232 255 / 0.16);
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 0.36rem;
min-block-size: 2rem;
border: 1px solid rgb(62 232 255 / 0.4);
border-radius: 999px;
padding: 0.24rem 0.72rem;
background:
linear-gradient(180deg, rgb(10 28 32 / 0.96), rgb(6 15 18 / 0.92)),
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.14), transparent 58%);
color: rgb(227 251 255 / 0.98);
font-size: 0.74rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
box-shadow 200ms ease,
opacity 180ms ease;
}
.export-btn svg {
inline-size: 0.84rem;
block-size: 0.84rem;
stroke: currentColor;
stroke-width: 1.35;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.export-btn:hover:not(:disabled) {
border-color: rgb(115 245 255 / 0.62);
box-shadow:
inset 0 0 0 1px rgb(231 255 255 / 0.08),
0 0 12px rgb(62 232 255 / 0.14);
}
.export-btn:disabled {
opacity: 0.72;
cursor: default;
}
.import-btn {
display: inline-flex;
align-items: center;
gap: 0.36rem;
min-block-size: 2rem;
border: 1px solid rgb(122 198 255 / 0.36);
border-radius: 999px;
padding: 0.24rem 0.72rem;
background:
linear-gradient(180deg, rgb(11 22 32 / 0.94), rgb(7 13 20 / 0.9)),
radial-gradient(circle at 50% 0, rgb(122 198 255 / 0.13), transparent 58%);
color: rgb(226 243 255 / 0.97);
font-size: 0.74rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease,
box-shadow 200ms ease;
}
.import-btn svg {
inline-size: 0.84rem;
block-size: 0.84rem;
stroke: currentColor;
stroke-width: 1.35;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.import-btn:hover {
border-color: rgb(164 220 255 / 0.6);
box-shadow:
inset 0 0 0 1px rgb(231 244 255 / 0.08),
0 0 12px rgb(122 198 255 / 0.14);
}
.hidden-input {
position: absolute;
inline-size: 0;
block-size: 0;
opacity: 0;
pointer-events: none;
}
.connection-notice {
margin: 0;
border: 1px solid rgb(95 132 158 / 0.32);
border-radius: 0.5rem;
padding: 0.45rem 0.7rem;
background: rgb(8 14 19 / 0.72);
color: rgb(214 236 248 / 0.96);
font-size: 0.72rem;
letter-spacing: 0.03em;
}
.connection-notice.tone-warn {
border-color: rgb(255 91 63 / 0.42);
background: rgb(28 12 11 / 0.78);
color: rgb(255 218 208 / 0.96);
}
.connection-notice.tone-ok {
border-color: rgb(133 255 68 / 0.4);
background: rgb(18 26 16 / 0.76);
color: rgb(228 255 214 / 0.96);
}
.connection-notice.tone-info {
border-color: rgb(62 232 255 / 0.34);
background: rgb(8 17 22 / 0.76);
color: rgb(214 236 248 / 0.96);
}
.info-grid {
display: flex;
align-items: center;
gap: 1.05rem;
flex-wrap: wrap;
padding-inline: 0.15rem;
}
.info-cell {
min-width: 0;
display: inline-flex;
align-items: baseline;
gap: 0.42rem;
}
.meta-label {
margin: 0;
font-size: 0.58rem;
letter-spacing: 0.1em;
color: rgb(140 163 181 / 0.82);
text-transform: uppercase;
}
.meta-value {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.03em;
color: rgb(205 228 245 / 0.92);
}
.locale-switch {
display: inline-flex;
align-items: center;
gap: 0.34rem;
border: 1px solid rgb(95 132 158 / 0.34);
border-radius: 999px;
padding: 0.18rem;
background: rgb(10 16 20 / 0.7);
}
.config-links {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-block-size: 2rem;
border: 1px solid rgb(95 132 158 / 0.36);
border-radius: 999px;
padding: 0.17rem 0.2rem;
background: linear-gradient(180deg, rgb(9 15 19 / 0.9), rgb(4 8 12 / 0.86));
box-shadow: inset 0 0 0 1px rgb(140 184 210 / 0.06);
}
.config-link {
display: inline-flex;
align-items: center;
gap: 0.34rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.26rem 0.64rem;
background: transparent;
color: rgb(164 188 208 / 0.9);
font-size: 0.81rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
color 180ms ease,
border-color 180ms ease,
background-color 180ms ease,
box-shadow 220ms ease;
}
.config-indicator {
inline-size: 0.34rem;
block-size: 0.34rem;
border-radius: 999px;
background: rgb(136 157 174 / 0.88);
box-shadow: 0 0 0 2px rgb(136 157 174 / 0.16);
transition:
background-color 180ms ease,
box-shadow 200ms ease;
}
.config-label {
line-height: 1;
}
.config-link:hover {
color: #d7edfb;
border-color: rgb(62 232 255 / 0.26);
}
.config-link.is-active {
color: #f1fdff;
border-color: rgb(106 150 180 / 0.56);
background: rgb(18 27 35 / 0.9);
box-shadow:
inset 0 0 0 1px rgb(167 218 252 / 0.08),
0 0 10px rgb(62 232 255 / 0.12);
}
.config-link.tone-cyan.is-active {
border-color: rgb(62 232 255 / 0.48);
}
.config-link.tone-lime.is-active {
border-color: rgb(133 255 68 / 0.52);
}
.config-link.tone-orange.is-active {
border-color: rgb(255 91 63 / 0.52);
}
.config-link.tone-cyan.is-active .config-indicator {
background: var(--hud-cyan);
box-shadow: 0 0 0 2px rgb(62 232 255 / 0.17);
}
.config-link.tone-lime.is-active .config-indicator {
background: var(--hud-lime);
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.17);
}
.config-link.tone-orange.is-active .config-indicator {
background: var(--hud-orange);
box-shadow: 0 0 0 2px rgb(255 91 63 / 0.18);
}
.locale-btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.26rem 0.6rem;
background: transparent;
color: rgb(150 173 189 / 0.9);
font-size: 0.7rem;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 180ms ease,
border-color 180ms ease,
background-color 180ms ease;
}
.locale-btn:hover {
color: #d7edfb;
border-color: rgb(62 232 255 / 0.3);
}
.locale-btn.is-active {
color: #f1fdff;
border-color: rgb(133 255 68 / 0.48);
background: rgb(24 31 25 / 0.9);
}
@media (max-width: 1080px) {
.config-links {
flex-wrap: wrap;
}
}
@media (max-width: 820px) {
.control-main-row {
gap: 0.44rem;
}
.config-links {
flex-wrap: wrap;
}
.serial-select {
padding-inline-start: 0.45rem;
}
.info-grid {
gap: 0.84rem;
}
}
@media (max-width: 620px) {
.suite-tag {
display: none;
}
.control-main-row {
align-items: stretch;
}
.serial-select {
inline-size: 100%;
justify-content: space-between;
}
.info-grid {
gap: 0.42rem 0.8rem;
}
.info-cell {
inline-size: 100%;
justify-content: space-between;
}
.window-btn {
inline-size: 1.75rem;
}
}
</style>

View File

@@ -0,0 +1,654 @@
<script lang="ts">
import { onMount } from "svelte";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { pressureColorPalettes } from "$lib/config/color-map";
import type { PressureColorMapPreset } from "$lib/types/hud";
interface ViewerStats {
total: number;
max: number;
avg: number;
}
interface MatrixLayout {
cellSpacing: number;
boardWidth: number;
boardDepth: number;
boardPadding: number;
gridSpan: number;
gridDivisions: number;
labelScale: number;
labelFloatOffset: number;
}
export let pressureMatrix: number[] | null = null;
export let matrixRows = 12;
export let matrixCols = 7;
export let rangeMin = 0;
export let rangeMax = 5000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
let viewerEl: HTMLDivElement | undefined;
let canvasEl: HTMLCanvasElement | undefined;
let overlayEl: HTMLCanvasElement | undefined;
let stats: ViewerStats = { total: 0, max: 0, avg: 0 };
const RAW_DATA_MAX = 5000;
const BASE_MATRIX_SPAN = 24;
const MATRIX_SPAN_GROWTH = 0.6;
const MIN_MATRIX_SPAN = 24;
const MAX_MATRIX_SPAN = 58;
const MIN_CELL_SPACING = 0.52;
const MAX_CELL_SPACING = 3.8;
const MIN_BOARD_PADDING = 2.6;
const MAX_BOARD_PADDING = 6.8;
const MIN_GRID_DIVISIONS = 12;
const MAX_GRID_DIVISIONS = 48;
const MIN_LABEL_SCALE = 0.72;
const MAX_LABEL_SCALE = 2.45;
const MATRIX_OFFSET_Y = -2.4;
const MATRIX_OFFSET_Z = 12;
const HEIGHT_SCALE = 18.5;
const BASE_HEIGHT = 0.18;
const GLOW_START = 0.3;
const SMOOTHING_SPEED = 8.2;
const CAMERA_FOV = 36;
const CAMERA_DISTANCE_MIN = 30;
const CAMERA_DISTANCE_MAX = 122;
const CAMERA_FIT_PADDING = 1.04;
const CAMERA_ELEVATION_DEG = 64;
const CAMERA_TARGET_X = 0;
const CAMERA_TARGET_Y = MATRIX_OFFSET_Y + 0.2;
const CAMERA_TARGET_Z = MATRIX_OFFSET_Z - 0.4;
const MATRIX_ROTATION_Y = 0;
const labelVector = new THREE.Vector3();
const whiteColor = new THREE.Color("#ffffff");
$: resolvedColorPalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
$: surfaceBaseColor = new THREE.Color(resolvedColorPalette.surfaceBase);
$: surfaceLowColor = new THREE.Color(resolvedColorPalette.surfaceLow);
$: surfaceMidColor = new THREE.Color(resolvedColorPalette.surfaceMid);
$: surfaceHighColor = new THREE.Color(resolvedColorPalette.surfaceHigh);
$: surfaceHotColor = new THREE.Color(resolvedColorPalette.surfaceHot);
$: labelZeroColor = new THREE.Color(resolvedColorPalette.labelZero);
$: labelLowColor = new THREE.Color(resolvedColorPalette.labelLow);
$: labelMidColor = new THREE.Color(resolvedColorPalette.labelMid);
$: labelHighColor = new THREE.Color(resolvedColorPalette.labelHigh);
function sanitizeGridValue(value: number): number {
return clamp(Math.round(Number.isFinite(value) ? value : 12), 1, 128);
}
function sanitizeRangePair(minValue: number, maxValue: number): { min: number; max: number } {
const resolvedMin = Math.round(Number.isFinite(minValue) ? minValue : 0);
const resolvedMax = Math.max(Math.round(Number.isFinite(maxValue) ? maxValue : RAW_DATA_MAX), resolvedMin + 1);
return { min: resolvedMin, max: resolvedMax };
}
$: resolvedMatrixRows = sanitizeGridValue(matrixRows);
$: resolvedMatrixCols = sanitizeGridValue(matrixCols);
$: resolvedRange = sanitizeRangePair(rangeMin, rangeMax);
$: resolvedRangeMin = resolvedRange.min;
$: resolvedRangeMax = resolvedRange.max;
$: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols);
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / color range ${resolvedRangeMin}-${resolvedRangeMax} / label raw`;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function normalizeRawValue(value: number, minValue: number, maxValue: number): number {
return clamp((value - minValue) / Math.max(1, maxValue - minValue), 0, 1);
}
function surfaceColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
const value = clamp(valueNormalized, 0, 1);
let mapped: THREE.Color;
if (value <= 0.45) {
const t = smoothstep(0, 0.45, value);
mapped = target.copy(surfaceBaseColor).lerp(surfaceLowColor, t);
} else if (value <= 0.78) {
const t = smoothstep(0.45, 0.78, value);
mapped = target.copy(surfaceLowColor).lerp(surfaceMidColor, t);
} else {
const t = smoothstep(0.78, 1, value);
mapped = target.copy(surfaceMidColor).lerp(surfaceHighColor, t);
}
const highlightStrength = smoothstep(0.82, 1, value) * 0.3;
return mapped.lerp(surfaceHotColor, highlightStrength);
}
function glowColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
const value = clamp(valueNormalized, 0, 1);
const glowStrength = smoothstep(0.55, 1, value);
return surfaceColorMap(value, target).lerp(whiteColor, glowStrength * 0.42);
}
function labelColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
const value = clamp(valueNormalized, 0, 1);
let mapped: THREE.Color;
if (value <= 0.34) {
const t = smoothstep(0, 0.34, value);
mapped = target.copy(labelZeroColor).lerp(labelLowColor, t);
} else if (value <= 0.76) {
const t = smoothstep(0.34, 0.76, value);
mapped = target.copy(labelLowColor).lerp(labelMidColor, t);
} else {
const t = smoothstep(0.76, 1, value);
mapped = target.copy(labelMidColor).lerp(labelHighColor, t);
}
const highlightStrength = smoothstep(0.8, 1, value) * 0.36;
return mapped.lerp(whiteColor, highlightStrength);
}
function shapeHeightValue(valueNormalized: number): number {
return Math.pow(clamp(valueNormalized, 0, 1), 0.74);
}
function shapeGlowStrength(valueNormalized: number): number {
return smoothstep(GLOW_START, 1, Math.pow(clamp(valueNormalized, 0, 1), 0.82));
}
function buildMatrixLayout(rows: number, cols: number): MatrixLayout {
const longestEdge = Math.max(rows, cols, 1);
const edgeSpan = Math.max(longestEdge - 1, 1);
const targetSpan = clamp(BASE_MATRIX_SPAN + edgeSpan * MATRIX_SPAN_GROWTH, MIN_MATRIX_SPAN, MAX_MATRIX_SPAN);
const cellSpacing = clamp(targetSpan / edgeSpan, MIN_CELL_SPACING, MAX_CELL_SPACING);
const boardWidth = Math.max(cols, 1) * cellSpacing;
const boardDepth = Math.max(rows, 1) * cellSpacing;
const boardPadding = clamp(cellSpacing * 1.62, MIN_BOARD_PADDING, MAX_BOARD_PADDING);
const gridSpan = Math.max(boardWidth, boardDepth) + boardPadding * 2;
const gridDivisions = Math.round(clamp(longestEdge * 1.6, MIN_GRID_DIVISIONS, MAX_GRID_DIVISIONS));
const labelScale = clamp(24 / (longestEdge + 2), MIN_LABEL_SCALE, MAX_LABEL_SCALE);
const labelFloatOffset = clamp(cellSpacing * 0.74, 0.64, 2.2);
return {
cellSpacing,
boardWidth,
boardDepth,
boardPadding,
gridSpan,
gridDivisions,
labelScale,
labelFloatOffset
};
}
function fitCameraDistance(boardWidth: number, boardDepth: number, boardPadding: number, viewportAspect: number): number {
const paddedWidth = boardWidth + boardPadding * 2;
const paddedDepth = boardDepth + boardPadding * 2;
const safeAspect = Math.max(viewportAspect, 0.5);
const effectiveHalfSpan = Math.max(paddedDepth * 0.5, (paddedWidth * 0.5) / safeAspect);
const fovRadians = THREE.MathUtils.degToRad(CAMERA_FOV * 0.5);
const fitDistance = (effectiveHalfSpan / Math.tan(fovRadians)) * CAMERA_FIT_PADDING;
return clamp(fitDistance, CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX);
}
function normalizeField(source: Float32Array, target: Float32Array, minValue: number, maxValue: number): number {
let max = 0;
for (let index = 0; index < source.length; index += 1) {
const value = source[index];
target[index] = normalizeRawValue(value, minValue, maxValue);
max = Math.max(max, value);
}
return max;
}
function copyExternalField(target: Float32Array, values: number[]): void {
for (let index = 0; index < target.length; index += 1) {
target[index] = clamp(values[index] ?? 0, 0, RAW_DATA_MAX);
}
}
function compactDisplayValue(rawValue: number, minValue: number, maxValue: number): number {
if (rawValue <= minValue + 4) {
return 0;
}
return Math.round(clamp(rawValue, 0, Math.max(maxValue, RAW_DATA_MAX)));
}
function colorToCss(color: THREE.Color): string {
return `rgb(${Math.round(color.r * 255)} ${Math.round(color.g * 255)} ${Math.round(color.b * 255)})`;
}
$: labelPalette = Array.from({ length: 33 }, (_, index) => {
const t = index / 32;
return colorToCss(labelColorMap(t, new THREE.Color()));
});
onMount(() => {
if (!viewerEl || !canvasEl || !overlayEl) {
return;
}
const gridRows = resolvedMatrixRows;
const gridCols = resolvedMatrixCols;
const { cellSpacing, boardWidth, boardDepth, boardPadding, gridSpan, gridDivisions, labelScale, labelFloatOffset } =
matrixLayout;
const instanceCount = gridRows * gridCols;
const overlayContext = overlayEl.getContext("2d");
if (!overlayContext) {
return;
}
const renderer = new THREE.WebGLRenderer({
canvas: canvasEl,
antialias: true,
alpha: true,
powerPreference: "high-performance"
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x06080a, 1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(CAMERA_FOV, 1, 0.1, 500);
const cameraElevation = THREE.MathUtils.degToRad(CAMERA_ELEVATION_DEG);
const updateCameraPlacement = (viewportWidth: number, viewportHeight: number) => {
const aspect = viewportWidth / Math.max(viewportHeight, 1);
const cameraDistance = fitCameraDistance(boardWidth, boardDepth, boardPadding, aspect);
const heightOffset = Math.sin(cameraElevation) * cameraDistance;
const depthOffset = Math.cos(cameraElevation) * cameraDistance;
camera.position.set(CAMERA_TARGET_X, CAMERA_TARGET_Y + heightOffset, CAMERA_TARGET_Z + depthOffset);
camera.lookAt(CAMERA_TARGET_X, CAMERA_TARGET_Y, CAMERA_TARGET_Z);
};
updateCameraPlacement(1, 1);
camera.lookAt(CAMERA_TARGET_X, CAMERA_TARGET_Y, CAMERA_TARGET_Z);
const controls = new OrbitControls(camera, canvasEl);
controls.enableRotate = false;
controls.enableZoom = false;
controls.enablePan = false;
controls.enableDamping = false;
controls.target.set(CAMERA_TARGET_X, CAMERA_TARGET_Y, CAMERA_TARGET_Z);
controls.enabled = false;
const ambientLight = new THREE.AmbientLight(0xffffff, 0.26);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.34);
dirLight.position.set(50, 100, 50);
const sideLight = new THREE.DirectionalLight(0x7cffba, 0.16);
sideLight.position.set(-50, 50, -50);
scene.add(ambientLight, dirLight, sideLight);
const matrixGroup = new THREE.Group();
matrixGroup.position.set(0, MATRIX_OFFSET_Y, MATRIX_OFFSET_Z);
matrixGroup.rotation.y = MATRIX_ROTATION_Y;
scene.add(matrixGroup);
const board = new THREE.Mesh(
new THREE.PlaneGeometry(boardWidth + boardPadding * 2, boardDepth + boardPadding * 2),
new THREE.MeshBasicMaterial({
color: 0x05070a,
transparent: true,
opacity: 0.12,
toneMapped: false
})
);
board.rotation.x = -Math.PI / 2;
board.position.y = -0.04;
matrixGroup.add(board);
const grid = new THREE.GridHelper(gridSpan, gridDivisions, 0x1a2630, 0x0a1015);
grid.position.y = 0;
const gridMaterial = grid.material;
if (Array.isArray(gridMaterial)) {
for (const material of gridMaterial) {
material.transparent = true;
material.opacity = 0.028;
}
} else {
gridMaterial.transparent = true;
gridMaterial.opacity = 0.028;
}
matrixGroup.add(grid);
const cellX = new Float32Array(instanceCount);
const cellZ = new Float32Array(instanceCount);
for (let row = 0; row < gridRows; row += 1) {
for (let col = 0; col < gridCols; col += 1) {
const index = row * gridCols + col;
cellX[index] = (col - gridCols / 2 + 0.5) * cellSpacing;
cellZ[index] = (row - gridRows / 2 + 0.5) * cellSpacing;
}
}
const targetField = new Float32Array(instanceCount);
const smoothedField = new Float32Array(instanceCount);
const normalizedField = new Float32Array(instanceCount);
const heightField = new Float32Array(instanceCount);
const compactField = new Uint16Array(instanceCount);
let lastFrameAt = performance.now();
const drawNumberOverlay = () => {
if (!viewerEl || !overlayEl) {
return;
}
const width = viewerEl.clientWidth;
const height = viewerEl.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const fontSize = clamp((Math.min(width, height) / 66) * labelScale + cellSpacing * 1.1, 6.4, 26);
overlayContext.setTransform(dpr, 0, 0, dpr, 0, 0);
overlayContext.clearRect(0, 0, width, height);
overlayContext.textAlign = "center";
overlayContext.textBaseline = "middle";
for (let index = 0; index < instanceCount; index += 1) {
labelVector.set(cellX[index], heightField[index] + labelFloatOffset, cellZ[index]);
labelVector.applyMatrix4(matrixGroup.matrixWorld);
labelVector.project(camera);
if (labelVector.z < -1 || labelVector.z > 1) {
continue;
}
const screenX = (labelVector.x * 0.5 + 0.5) * width;
const screenY = (-labelVector.y * 0.5 + 0.5) * height;
if (screenX < -12 || screenX > width + 12 || screenY < -12 || screenY > height + 12) {
continue;
}
const normalized = normalizedField[index];
const displayValue = compactField[index];
const displayText = String(displayValue);
const digitCount = displayText.length;
const digitScale = digitCount <= 2 ? 1 : digitCount === 3 ? 0.86 : digitCount === 4 ? 0.74 : 0.64;
const bucket = Math.min(32, Math.round(normalized * 32));
const baseGlyphSize = fontSize + smoothstep(0, 1, normalized) * (2.3 * labelScale + cellSpacing * 0.26);
const glyphSize = clamp(baseGlyphSize * digitScale, 5.2, 26);
const glowSizeFactor = digitCount >= 4 ? 0.76 : digitCount === 3 ? 0.88 : 1;
const glowBlur = (4.8 + smoothstep(0.08, 1, normalized) * (10.4 * Math.max(0.72, labelScale))) * glowSizeFactor;
overlayContext.font = `600 ${glyphSize.toFixed(1)}px "JetBrains Mono", "IBM Plex Mono", "Consolas", monospace`;
overlayContext.shadowBlur = glowBlur;
overlayContext.shadowColor = labelPalette[bucket];
overlayContext.fillStyle = labelPalette[bucket];
overlayContext.globalAlpha = displayValue === 0 ? 0.86 : 1;
overlayContext.fillText(displayText, screenX, screenY);
if (normalized >= 0.8) {
overlayContext.fillStyle = "rgb(255 245 220)";
overlayContext.globalAlpha = smoothstep(0.8, 1, normalized) * 0.34;
overlayContext.fillText(displayText, screenX, screenY);
}
}
overlayContext.globalAlpha = 1;
overlayContext.shadowBlur = 0;
};
const resize = () => {
if (!viewerEl || !overlayEl) {
return;
}
const width = viewerEl.clientWidth;
const height = viewerEl.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (width <= 0 || height <= 0) {
return;
}
renderer.setSize(width, height, false);
updateCameraPlacement(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
overlayEl.width = Math.round(width * dpr);
overlayEl.height = Math.round(height * dpr);
overlayEl.style.width = `${width}px`;
overlayEl.style.height = `${height}px`;
};
resize();
const resizeObserver = new ResizeObserver(() => {
resize();
});
resizeObserver.observe(viewerEl);
renderer.setAnimationLoop((timestamp: number) => {
const deltaSeconds = Math.min((timestamp - lastFrameAt) / 1000, 0.06);
lastFrameAt = timestamp;
let shouldHardResetToZero = true;
if (pressureMatrix && pressureMatrix.length > 0) {
copyExternalField(targetField, pressureMatrix);
for (let index = 0; index < instanceCount; index += 1) {
if (targetField[index] > 0) {
shouldHardResetToZero = false;
break;
}
}
} else {
targetField.fill(0);
}
if (shouldHardResetToZero) {
smoothedField.fill(0);
}
const smoothing = 1 - Math.exp(-deltaSeconds * SMOOTHING_SPEED);
for (let index = 0; index < instanceCount; index += 1) {
smoothedField[index] += (targetField[index] - smoothedField[index]) * smoothing;
}
const maxValue = normalizeField(smoothedField, normalizedField, resolvedRangeMin, resolvedRangeMax);
let total = 0;
let activeCount = 0;
for (let index = 0; index < instanceCount; index += 1) {
const normalized = normalizedField[index];
const heightValue = shapeHeightValue(normalized);
const height = BASE_HEIGHT + heightValue * HEIGHT_SCALE;
heightField[index] = height;
compactField[index] = compactDisplayValue(smoothedField[index], resolvedRangeMin, resolvedRangeMax);
total += smoothedField[index];
if (smoothedField[index] > 30) {
activeCount += 1;
}
}
renderer.render(scene, camera);
drawNumberOverlay();
stats = {
total,
max: maxValue,
avg: activeCount > 0 ? total / activeCount : 0
};
});
return () => {
resizeObserver.disconnect();
renderer.setAnimationLoop(null);
controls.dispose();
board.geometry.dispose();
board.material.dispose();
if (Array.isArray(gridMaterial)) {
for (const item of gridMaterial) {
item.dispose();
}
} else {
gridMaterial.dispose();
}
renderer.dispose();
};
});
</script>
<div class="viewer-root" bind:this={viewerEl}>
<canvas class="viewer-canvas" bind:this={canvasEl} aria-label="Pressure Matrix Viewer"></canvas>
<canvas class="viewer-overlay" bind:this={overlayEl} aria-hidden="true"></canvas>
<div class="viewer-vignette" aria-hidden="true"></div>
<div class="viewer-noise" aria-hidden="true"></div>
<div class="viewer-controls">
<section class="stats-panel" aria-label="Pressure Summary">
<p class="stats-label">Pressure Matrix</p>
<div class="stats-grid">
<article class="stats-card stats-card-wide">
<span class="stats-key">Total Pressure</span>
<strong class="stats-value">{stats.total.toFixed(0)}</strong>
</article>
<article class="stats-card">
<span class="stats-key">Max</span>
<strong class="stats-value">{stats.max.toFixed(0)}</strong>
</article>
<article class="stats-card">
<span class="stats-key">Avg</span>
<strong class="stats-value">{stats.avg.toFixed(0)}</strong>
</article>
</div>
<p class="stats-note">{statsNote}</p>
</section>
</div>
</div>
<style>
.viewer-root {
position: absolute;
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 58%, rgb(72 127 255 / 0.065), transparent 32%),
radial-gradient(circle at 50% 12%, rgb(151 231 255 / 0.05), transparent 26%),
linear-gradient(180deg, rgb(10 13 17 / 0.82), rgb(5 7 10 / 0.96));
}
.viewer-canvas,
.viewer-overlay {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
display: block;
}
.viewer-overlay {
pointer-events: none;
z-index: 1;
}
.viewer-vignette,
.viewer-noise {
position: absolute;
inset: 0;
pointer-events: none;
}
.viewer-vignette {
background: radial-gradient(circle at center, transparent 54%, rgb(0 0 0 / 0.18) 100%);
}
.viewer-noise {
background: repeating-linear-gradient(180deg, rgb(124 165 216 / 0.02) 0, rgb(124 165 216 / 0.02) 1px, transparent 1px, transparent 3px);
}
.viewer-controls {
position: absolute;
top: clamp(4.8rem, 10vh, 6.2rem);
left: clamp(2.6rem, 4vw, 3.4rem);
display: grid;
gap: 0.75rem;
z-index: 2;
max-inline-size: min(18rem, 32vw);
}
.stats-panel {
display: grid;
gap: 0.58rem;
padding: 0.74rem 0.84rem 0.82rem;
border: 1px solid rgb(86 151 118 / 0.32);
border-radius: 0.76rem;
background: linear-gradient(180deg, rgb(7 17 14 / 0.9), rgb(5 10 9 / 0.84));
box-shadow: inset 0 1px 0 rgb(171 224 197 / 0.08), 0 0 24px rgb(42 138 88 / 0.08);
}
.stats-label,
.stats-key,
.stats-note {
margin: 0;
color: rgb(165 212 187 / 0.84);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.46rem;
}
.stats-card {
display: grid;
gap: 0.24rem;
min-height: 4.2rem;
padding: 0.58rem 0.64rem;
border: 1px solid rgb(71 122 96 / 0.24);
border-radius: 0.56rem;
background: linear-gradient(180deg, rgb(8 16 14 / 0.88), rgb(5 9 8 / 0.84));
}
.stats-card-wide {
grid-column: 1 / -1;
}
.stats-value {
color: rgb(240 246 255 / 0.98);
font-size: clamp(1.16rem, 1vw + 0.82rem, 1.56rem);
line-height: 1;
font-weight: 600;
}
@media (max-width: 960px) {
.viewer-controls {
left: clamp(1rem, 2.4vw, 1.4rem);
max-inline-size: min(13.5rem, 42vw);
}
.stats-grid {
grid-template-columns: 1fr;
}
.stats-card-wide {
grid-column: auto;
}
}
@media (max-height: 760px) {
.viewer-controls {
top: clamp(4rem, 8vh, 4.8rem);
}
.stats-panel {
padding: 0.62rem 0.7rem;
}
.stats-card {
min-height: 3.6rem;
}
}
</style>

View File

@@ -0,0 +1,452 @@
<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(130 174 202 / 0.42);
border-radius: 0.92rem;
padding: 0.56rem 0.62rem 0.58rem;
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;
}
.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(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;
gap: 0.26rem;
justify-content: flex-end;
align-items: center;
}
.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);
}
.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(132 174 200 / 0.32);
border-radius: 0.62rem;
overflow: hidden;
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-line-group line {
stroke: rgb(138 184 210 / 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(62 232 255 / 0.95);
}
.series-line.tone-lime {
stroke: rgb(133 255 68 / 0.94);
}
.series-line.tone-orange {
stroke: rgb(255 91 63 / 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(146 191 214 / 0.04) 0,
rgb(146 191 214 / 0.04) 1px,
transparent 1px,
transparent 3px
),
linear-gradient(180deg, transparent 0%, rgb(62 232 255 / 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(173 206 227 / 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(62 232 255);
}
.dot.tone-lime {
background: rgb(133 255 68);
}
.dot.tone-orange {
background: rgb(255 91 63);
}
.metric-label {
color: rgb(144 172 191 / 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>

View 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>

View File

@@ -0,0 +1,57 @@
import type { PressureColorMapPreset } from "$lib/types/hud";
export interface PressureColorPalette {
surfaceBase: string;
surfaceLow: string;
surfaceMid: string;
surfaceHigh: string;
surfaceHot: string;
labelZero: string;
labelLow: string;
labelMid: string;
labelHigh: string;
rangeStops: [string, string, string, string, string, string];
rangeGlow: [string, string, string];
}
export const pressureColorPalettes: Record<PressureColorMapPreset, PressureColorPalette> = {
emerald: {
surfaceBase: "#13201a",
surfaceLow: "#285338",
surfaceMid: "#3f8a66",
surfaceHigh: "#6dd3ad",
surfaceHot: "#d9fff0",
labelZero: "#2d8d59",
labelLow: "#54df8e",
labelMid: "#98e6ff",
labelHigh: "#ffab78",
rangeStops: ["#13201a", "#285338", "#3f8a66", "#6dd3ad", "#98e6ff", "#ffab78"],
rangeGlow: ["#54df8e", "#98e6ff", "#ffab78"]
},
arctic: {
surfaceBase: "#08141d",
surfaceLow: "#14354d",
surfaceMid: "#1f6690",
surfaceHigh: "#58bee8",
surfaceHot: "#f1fdff",
labelZero: "#3f87ae",
labelLow: "#6dc8ff",
labelMid: "#aef3ff",
labelHigh: "#ffffff",
rangeStops: ["#08141d", "#14354d", "#1f6690", "#58bee8", "#aef3ff", "#ffffff"],
rangeGlow: ["#5ea9ff", "#7fe5ff", "#ffffff"]
},
ember: {
surfaceBase: "#1b0c08",
surfaceLow: "#4a1f15",
surfaceMid: "#8f4124",
surfaceHigh: "#d9772f",
surfaceHot: "#fff1d8",
labelZero: "#b9582f",
labelLow: "#ff8a4e",
labelMid: "#ffd06a",
labelHigh: "#fff4df",
rangeStops: ["#1b0c08", "#4a1f15", "#8f4124", "#d9772f", "#ffd06a", "#fff4df"],
rangeGlow: ["#ff7247", "#ffb14d", "#fff4df"]
}
};

50
src/lib/styles/theme.css Normal file
View File

@@ -0,0 +1,50 @@
:root {
color-scheme: dark;
--hud-bg-00: #000000;
--hud-bg-10: #050607;
--hud-bg-20: #0b0e11;
--hud-bg-30: #030405;
--hud-cyan: #3ee8ff;
--hud-lime: #85ff44;
--hud-orange: #ff5b3f;
--hud-range-0: #13201a;
--hud-range-1: #285338;
--hud-range-2: #3f8a66;
--hud-range-3: #6dd3ad;
--hud-range-4: #98e6ff;
--hud-range-5: #ffab78;
--hud-text-main: #cfe7ff;
--hud-text-dim: #86a2b8;
/* Keep root surface close to the main board style to avoid visible drag-edge seams. */
background:
radial-gradient(circle at 18% 8%, rgb(62 232 255 / 0.05), transparent 38%),
radial-gradient(circle at 84% 14%, rgb(133 255 68 / 0.04), transparent 36%),
linear-gradient(165deg, var(--hud-bg-20) 0%, var(--hud-bg-10) 48%, var(--hud-bg-30) 100%);
background-color: var(--hud-bg-00);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
overscroll-behavior: none;
}
body {
font-family: "Rajdhani", "Segoe UI", "PingFang SC", sans-serif;
color: var(--hud-text-main);
background: inherit;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}

144
src/lib/types/hud.ts Normal file
View File

@@ -0,0 +1,144 @@
export type LocaleCode = "zh-CN" | "en-US";
export type WindowControlAction = "minimize" | "toggle-maximize" | "close";
export type ConnectionState = "online" | "connecting" | "offline";
export type StageStatusTone = "ok" | "warn" | "idle";
export type HudNoticeTone = "ok" | "warn" | "info";
export type SignalTone = "cyan" | "lime" | "orange" | "violet" | "gold" | "rose";
export type PressureColorMapPreset = "emerald" | "arctic" | "ember";
export type SignalPanelSide = "left" | "right";
export type HudConfigTone = "neutral" | "cyan" | "lime" | "orange";
export interface HudSignalSeries {
id: string;
tone: SignalTone;
points: number[];
}
export interface HudSignalIcon {
id: string;
label: string;
tone: SignalTone;
}
export interface HudSignalPanel {
id: string;
code: string;
title: string;
side: SignalPanelSide;
active: boolean;
series: HudSignalSeries[];
icons: HudSignalIcon[];
latest: number | null;
min: number | null;
max: number | null;
}
export interface HudPacket {
ts: number;
panels: HudSignalPanel[];
summary: HudSummary;
pressureMatrix: number[] | null;
}
export interface HudSummary {
label: string;
points: number[];
latest: number | null;
min: number | null;
max: number | null;
}
export interface HudConfigLink {
id: string;
label: string;
tone?: HudConfigTone;
active?: boolean;
}
export interface HudColorMapOption {
id: PressureColorMapPreset;
label: string;
previewStops: [string, string, string];
}
export interface HudCopy {
appName: string;
suiteName: string;
stageTitle: string;
stageHint: string;
configPanelTitle: string;
configPanelHint: string;
matrixSizeLabel: string;
matrixRowsLabel: string;
matrixColsLabel: string;
rangeLabel: string;
rangeMinLabel: string;
rangeMaxLabel: string;
colorMapLabel: string;
resetConfigLabel: string;
applyLiveHint: string;
runtimeReady: string;
runtimeFallback: string;
controlArea: string;
serialPortLabel: string;
connectionLabel: string;
deviceLabel: string;
sampleRateLabel: string;
channelsLabel: string;
configLinksLabel: string;
refreshPortsLabel: string;
connectActionLabel: string;
disconnectActionLabel: string;
exportActionLabel: string;
exportingActionLabel: string;
importActionLabel: string;
replaySectionLabel: string;
replayPlayLabel: string;
replayPauseLabel: string;
replayStopLabel: string;
replaySpeedLabel: string;
replayProgressLabel: string;
replayEmptyHint: string;
connectedLabel: string;
connectingLabel: string;
disconnectedLabel: string;
}
export interface HudMatrixConfig {
rows: number;
cols: number;
rangeMin: number;
rangeMax: number;
colorMapPreset: PressureColorMapPreset;
}
export interface SerialConnectResult {
port: string;
connected: boolean;
message: string;
}
export interface SerialExportResult {
path: string;
frameCount: number;
message: string;
}
export interface SerialImportFrameResult {
data: number[];
dtsMs: number;
}
export interface SerialImportResult {
fileName: string;
frameCount: number;
channelCount: number;
frames: SerialImportFrameResult[];
message: string;
}

17
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { onMount } from "svelte";
onMount(() => {
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
document.addEventListener("contextmenu", handleContextMenu, true);
return () => {
document.removeEventListener("contextmenu", handleContextMenu, true);
};
});
</script>
<slot />

5
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

1445
src/routes/+page.svelte Normal file

File diff suppressed because it is too large Load Diff