first commit
This commit is contained in:
774
src/lib/components/CenterStage.svelte
Normal file
774
src/lib/components/CenterStage.svelte
Normal 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>
|
||||
460
src/lib/components/ConfigPanel.svelte
Normal file
460
src/lib/components/ConfigPanel.svelte
Normal 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>
|
||||
970
src/lib/components/HudPanel.svelte
Normal file
970
src/lib/components/HudPanel.svelte
Normal 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>
|
||||
654
src/lib/components/PressureMatrixViewer.svelte
Normal file
654
src/lib/components/PressureMatrixViewer.svelte
Normal 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>
|
||||
452
src/lib/components/SignalChart.svelte
Normal file
452
src/lib/components/SignalChart.svelte
Normal 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>
|
||||
436
src/lib/components/SummaryCurve.svelte
Normal file
436
src/lib/components/SummaryCurve.svelte
Normal file
@@ -0,0 +1,436 @@
|
||||
<script lang="ts">
|
||||
import type { HudSummary } from "$lib/types/hud";
|
||||
|
||||
export let summary: HudSummary;
|
||||
export let side: "left" | "right" = "right";
|
||||
export let panelIndex = 0;
|
||||
|
||||
const viewportWidth = 100;
|
||||
const viewportHeight = 36;
|
||||
const verticalInset = 2;
|
||||
|
||||
interface PlotPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function formatValue(value: number | null): string {
|
||||
if (value === null) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
function resolveBounds(points: number[]): { min: number; max: number } {
|
||||
if (points.length === 0) {
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
|
||||
const min = Math.min(...points);
|
||||
const max = Math.max(...points);
|
||||
|
||||
if (Math.abs(max - min) < 0.001) {
|
||||
return { min: min - 1, max: max + 1 };
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function convertPoints(rawPoints: number[]): PlotPoint[] {
|
||||
if (rawPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (rawPoints.length === 1) {
|
||||
return [{ x: viewportWidth / 2, y: viewportHeight / 2 }];
|
||||
}
|
||||
|
||||
const { min, max } = resolveBounds(rawPoints);
|
||||
const span = max - min;
|
||||
const chartHeight = viewportHeight - verticalInset * 2;
|
||||
const stepX = viewportWidth / (rawPoints.length - 1);
|
||||
|
||||
return rawPoints.map((point, index) => {
|
||||
const ratio = span <= 0 ? 0.5 : (point - min) / span;
|
||||
const y = viewportHeight - verticalInset - ratio * chartHeight;
|
||||
|
||||
return {
|
||||
x: Math.round(index * stepX * 100) / 100,
|
||||
y: Math.round(clamp(y, verticalInset, viewportHeight - verticalInset) * 100) / 100
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createLinePath(points: PlotPoint[]): string {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function createAreaPath(points: PlotPoint[]): string {
|
||||
if (points.length < 2) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const linePath = createLinePath(points);
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
|
||||
return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`;
|
||||
}
|
||||
|
||||
$: plotPoints = convertPoints(summary.points);
|
||||
$: linePath = createLinePath(plotPoints);
|
||||
$: areaPath = createAreaPath(plotPoints);
|
||||
$: lastPoint = plotPoints.length > 0 ? plotPoints[plotPoints.length - 1] : null;
|
||||
$: latestValue = formatValue(summary.latest);
|
||||
$: minValue = formatValue(summary.min);
|
||||
$: maxValue = formatValue(summary.max);
|
||||
$: sampleCount = summary.points.length;
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="signal-panel summary-panel side-{side}"
|
||||
class:is-empty={sampleCount === 0}
|
||||
aria-hidden={sampleCount === 0}
|
||||
style="--panel-index: {panelIndex};"
|
||||
>
|
||||
<header class="panel-head">
|
||||
<div class="head-text">
|
||||
<p class="panel-code">TOT</p>
|
||||
<p class="panel-title">{summary.label}</p>
|
||||
</div>
|
||||
|
||||
<div class="icon-layer" aria-hidden="true">
|
||||
<span class="icon-chip tone-cyan">NOW</span>
|
||||
<span class="icon-chip tone-lime">MIN</span>
|
||||
<span class="icon-chip tone-orange">MAX</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chart-stage">
|
||||
<svg viewBox="0 0 {viewportWidth} {viewportHeight}" preserveAspectRatio="none" role="img" aria-label={summary.label}>
|
||||
<defs>
|
||||
<linearGradient id="summary-fill" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgb(62 232 255 / 0.28)" />
|
||||
<stop offset="100%" stop-color="rgb(62 232 255 / 0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g class="grid-lines" aria-hidden="true">
|
||||
{#each [6, 12, 18, 24, 30] as y}
|
||||
<line x1="0" y1={y} x2={viewportWidth} y2={y}></line>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
{#if areaPath}
|
||||
<path d={areaPath} class="summary-area"></path>
|
||||
{/if}
|
||||
|
||||
{#if linePath}
|
||||
<path d={linePath} class="summary-line"></path>
|
||||
{/if}
|
||||
|
||||
{#if lastPoint}
|
||||
<circle cx={lastPoint.x} cy={lastPoint.y} r="1.7" class="summary-dot"></circle>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
{#if sampleCount === 0}
|
||||
<div class="empty-state">
|
||||
<span>Waiting</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="panel-foot">
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-cyan"></span>
|
||||
<span class="metric-text">Now</span>
|
||||
<span class="value">{latestValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-lime"></span>
|
||||
<span class="metric-text">Min</span>
|
||||
<span class="value">{minValue}</span>
|
||||
</p>
|
||||
<p class="foot-item">
|
||||
<span class="dot tone-orange"></span>
|
||||
<span class="metric-text">Max</span>
|
||||
<span class="value">{maxValue}</span>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.signal-panel {
|
||||
--offset-x: 12%;
|
||||
--enter-ms: 1800ms;
|
||||
--fade-ms: 1000ms;
|
||||
overflow: hidden;
|
||||
inline-size: min(100%, clamp(16.8rem, 23vw, 22rem));
|
||||
aspect-ratio: 1.44 / 1;
|
||||
min-block-size: 11.8rem;
|
||||
justify-self: start;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0.4rem;
|
||||
padding: 0.56rem 0.62rem 0.58rem;
|
||||
border: 1px solid rgb(130 174 202 / 0.42);
|
||||
border-radius: 0.92rem;
|
||||
background:
|
||||
linear-gradient(160deg, rgb(10 18 26 / 0.76) 0%, rgb(3 8 12 / 0.62) 48%, rgb(1 2 4 / 0.76) 100%),
|
||||
radial-gradient(circle at 12% 0, rgb(62 232 255 / 0.1), transparent 40%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(165 224 255 / 0.08),
|
||||
inset 0 -24px 32px rgb(0 0 0 / 0.48),
|
||||
0 0 14px rgb(62 232 255 / 0.14);
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1) rotate(0);
|
||||
transition:
|
||||
opacity var(--fade-ms) cubic-bezier(0.18, 0.88, 0.3, 1),
|
||||
transform var(--enter-ms) cubic-bezier(0.2, 0.9, 0.28, 1),
|
||||
border-color 460ms ease,
|
||||
filter 760ms ease;
|
||||
transition-delay: calc(var(--panel-index) * 140ms);
|
||||
}
|
||||
|
||||
.signal-panel.side-left {
|
||||
--offset-x: -132%;
|
||||
}
|
||||
|
||||
.signal-panel.side-right {
|
||||
--offset-x: 132%;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.summary-panel.is-empty {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.head-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-code {
|
||||
margin: 0;
|
||||
font-size: 0.63rem;
|
||||
color: rgb(153 188 211 / 0.88);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0.12rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: rgb(225 243 255 / 0.96);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.icon-layer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.26rem;
|
||||
}
|
||||
|
||||
.icon-chip {
|
||||
border: 1px solid rgb(138 178 204 / 0.44);
|
||||
border-radius: 999px;
|
||||
padding: 0.08rem 0.36rem;
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(209 237 255 / 0.94);
|
||||
background: rgb(5 13 20 / 0.66);
|
||||
}
|
||||
|
||||
.icon-chip.tone-cyan {
|
||||
border-color: rgb(62 232 255 / 0.54);
|
||||
}
|
||||
|
||||
.icon-chip.tone-lime {
|
||||
border-color: rgb(133 255 68 / 0.56);
|
||||
}
|
||||
|
||||
.icon-chip.tone-orange {
|
||||
border-color: rgb(255 91 63 / 0.58);
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
position: relative;
|
||||
block-size: clamp(6.4rem, 9vw, 8.2rem);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(132 174 200 / 0.32);
|
||||
border-radius: 0.62rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(8 17 26 / 0.68), rgb(1 6 10 / 0.78)),
|
||||
radial-gradient(circle at 50% 0, rgb(62 232 255 / 0.09), transparent 45%);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.grid-lines line {
|
||||
stroke: rgb(138 184 210 / 0.16);
|
||||
stroke-width: 0.45;
|
||||
}
|
||||
|
||||
.summary-area {
|
||||
fill: url(#summary-fill);
|
||||
}
|
||||
|
||||
.summary-line {
|
||||
fill: none;
|
||||
stroke: rgb(62 232 255 / 0.96);
|
||||
stroke-width: 1.35;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgb(62 232 255 / 0.22));
|
||||
}
|
||||
|
||||
.summary-dot {
|
||||
fill: rgb(133 255 68 / 0.98);
|
||||
filter: drop-shadow(0 0 6px rgb(133 255 68 / 0.3));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgb(155 186 204 / 0.76);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(180deg, rgb(2 7 11 / 0.06), rgb(2 7 11 / 0.18));
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.foot-item {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
color: rgb(173 206 227 / 0.9);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.metric-text {
|
||||
color: rgb(146 173 191 / 0.82);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dot {
|
||||
inline-size: 0.34rem;
|
||||
block-size: 0.34rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.tone-cyan {
|
||||
background: rgb(62 232 255);
|
||||
}
|
||||
|
||||
.dot.tone-lime {
|
||||
background: rgb(133 255 68);
|
||||
}
|
||||
|
||||
.dot.tone-orange {
|
||||
background: rgb(255 91 63);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-inline-size: 2.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(14rem, 30vw, 17rem));
|
||||
aspect-ratio: 1.5 / 1;
|
||||
min-block-size: 10.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 900px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(15rem, 22vw, 18.5rem));
|
||||
min-block-size: 10.6rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(13.8rem, 20vw, 16.5rem));
|
||||
min-block-size: 9.8rem;
|
||||
padding: 0.46rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-block-start: 0.28rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(5rem, 6.6vw, 6rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 680px) {
|
||||
.signal-panel {
|
||||
inline-size: min(100%, clamp(12.8rem, 18vw, 15rem));
|
||||
min-block-size: 8.7rem;
|
||||
padding: 0.4rem 0.46rem 0.44rem;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-block-end: 0.26rem;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-block-start: 0.18rem;
|
||||
gap: 0.56rem;
|
||||
}
|
||||
|
||||
.chart-stage {
|
||||
block-size: clamp(4.4rem, 5.6vw, 5.4rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.signal-panel {
|
||||
inline-size: 100%;
|
||||
aspect-ratio: 1.7 / 1;
|
||||
min-block-size: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/lib/config/color-map.ts
Normal file
57
src/lib/config/color-map.ts
Normal 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
50
src/lib/styles/theme.css
Normal 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
144
src/lib/types/hud.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user