feat:增加点和数字切换,减小点最大尺寸,增加range配色方案

This commit is contained in:
lenn
2026-04-09 09:17:07 +08:00
parent 1c3a811154
commit a3cefc3c79
78 changed files with 786 additions and 296 deletions

View File

@@ -14,15 +14,11 @@
HudSignalPanel,
HudSummary,
LocaleCode,
PressureColorMapPreset,
StageStatusTone
MatrixDisplayMode,
PressureColorMapPreset
} from "$lib/types/hud";
export let title = "";
export let hint = "";
export let locale: LocaleCode = "zh-CN";
export let statusText = "";
export let statusTone: StageStatusTone = "idle";
export let leftPanels: HudSignalPanel[] = [];
export let rightPanels: HudSignalPanel[] = [];
export let summary: HudSummary;
@@ -44,6 +40,7 @@
export let rangeMin = 0;
export let rangeMax = 16000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let colorMapOptions: HudColorMapOption[] = [];
export let replaySectionLabel = "";
export let replayPlayLabel = "";
@@ -60,7 +57,6 @@
export let showPrecisionTestPanel = false;
let stagePlaneEl: HTMLDivElement | undefined;
let topOverlayEl: HTMLDivElement | undefined;
let panelZoneEl: HTMLDivElement | undefined;
let leftStackEl: HTMLDivElement | undefined;
let rightStackEl: HTMLDivElement | undefined;
@@ -85,6 +81,7 @@
$: replaySide = summarySide === "left" ? "right" : "left";
$: replayToggleButtonText = replayIsPlaying ? replayPauseLabel : replayPlayLabel;
$: replayProgressPercent = Math.round(Math.min(1, Math.max(0, replayProgress)) * 100);
$: summaryCurveVisible = summary.points.length > 0 && summary.points.some((value) => Number.isFinite(value) && Math.abs(value) >= 0.0001);
$: splitMatrixTitle = locale === "zh-CN" ? "数字矩阵" : "Matrix";
$: splitMatrixHint = locale === "zh-CN" ? "实时压力数据 / 数字矩阵" : "Live pressure matrix";
@@ -107,15 +104,11 @@
}
function recomputePanelLayout(): void {
if (!stagePlaneEl || !topOverlayEl) {
if (!stagePlaneEl) {
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);
panelZoneTopPx = showPrecisionTestPanel ? 24 : 16;
const panelZoneBottomPx = panelZoneEl ? toPxNumber(getComputedStyle(panelZoneEl).bottom) : 0;
const zoneHeight = Math.max(0, stagePlaneEl.clientHeight - panelZoneTopPx - panelZoneBottomPx);
@@ -159,10 +152,6 @@
resizeObserver.observe(stagePlaneEl);
}
if (topOverlayEl) {
resizeObserver.observe(topOverlayEl);
}
if (leftStackEl) {
resizeObserver.observe(leftStackEl);
}
@@ -187,19 +176,6 @@
bind:this={stagePlaneEl}
style="--panel-zone-top-dyn: {panelZoneTopPx}px; --rail-scale-left: {leftRailScale}; --rail-scale-right: {rightRailScale};"
>
{#if !showPrecisionTestPanel}
<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>
{/if}
{#if showPrecisionTestPanel}
<div class="split-game-wrap">
<section class="split-panel split-matrix-panel">
@@ -210,12 +186,14 @@
<div class="split-panel-body">
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}:split`}
<PressureMatrixViewer
{summary}
{pressureMatrix}
{matrixRows}
{matrixCols}
{rangeMin}
{rangeMax}
{colorMapPreset}
{matrixDisplayMode}
showStatsPanel={true}
/>
{/key}
@@ -238,12 +216,14 @@
<div class="canvas-wrap">
{#key `${matrixRows}x${matrixCols}:${colorMapPreset}`}
<PressureMatrixViewer
{summary}
{pressureMatrix}
{matrixRows}
{matrixCols}
{rangeMin}
{rangeMax}
{colorMapPreset}
{matrixDisplayMode}
showStatsPanel={true}
/>
{/key}
@@ -290,7 +270,7 @@
</div>
{/each}
{#if summary.points.length > 0 && summarySide === "left"}
{#if summaryCurveVisible && summarySide === "left"}
<div
class="panel-motion-shell"
in:fly={{ x: -180, duration: 340, opacity: 0.08, easing: cubicOut }}
@@ -321,7 +301,7 @@
</div>
{/each}
{#if summary.points.length > 0 && summarySide === "right"}
{#if summaryCurveVisible && summarySide === "right"}
<div
class="panel-motion-shell"
in:fly={{ x: 180, duration: 340, opacity: 0.08, easing: cubicOut }}
@@ -427,75 +407,6 @@
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(var(--hud-border-rgb) / 0.2);
border-radius: 0.45rem;
background: rgb(var(--hud-surface-deep-rgb) / 0.45);
backdrop-filter: blur(2px);
}
.meta-label {
margin: 0;
font-size: 0.56rem;
color: rgb(var(--hud-text-dim-rgb) / 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(var(--hud-text-main-rgb) / 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(var(--hud-text-dim-rgb) / 0.76);
line-height: 1.15;
}
.runtime-status {
margin: 0;
align-self: center;
border: 1px solid rgb(var(--hud-border-rgb) / 0.35);
border-radius: 999px;
padding: 0.3rem 0.66rem;
font-size: 0.66rem;
letter-spacing: 0.08em;
color: rgb(var(--hud-text-dim-rgb) / 0.9);
text-transform: uppercase;
white-space: nowrap;
background: rgb(var(--hud-surface-deep-rgb) / 0.62);
}
.runtime-status.is-ok {
color: rgb(var(--hud-lime-rgb) / 0.94);
}
.runtime-status.is-warn {
color: rgb(var(--hud-orange-rgb) / 0.92);
}
.canvas-wrap {
position: absolute;
inset: 0;

View File

@@ -89,6 +89,10 @@
colorMapPreset = "emerald";
}
function handleSubmit(): void {
dispatch("close");
}
$: selectedColorMap = colorMapOptions.find((option) => option.id === colorMapPreset) ?? colorMapOptions[0];
$: {
@@ -120,7 +124,7 @@
}
</script>
<section class="config-panel" aria-label={title}>
<form class="config-panel" aria-label={title} on:submit|preventDefault={handleSubmit}>
<header class="config-head">
<div class="config-copy">
<p class="config-label">Stage Config</p>
@@ -214,7 +218,7 @@
<p class="live-note">{applyLiveHint}</p>
<button type="button" class="reset-btn" on:click={resetDefaults}>{resetLabel}</button>
</footer>
</section>
</form>
<style>
.config-panel {

View File

@@ -5,6 +5,7 @@
HudConfigLink,
HudNoticeTone,
LocaleCode,
MatrixDisplayMode,
WindowControlAction
} from "$lib/types/hud";
@@ -29,6 +30,10 @@
export let refreshPortsLabel = "";
export let configLinksLabel = "";
export let configLinks: HudConfigLink[] = [];
export let matrixViewLabel = "";
export let matrixViewNumericLabel = "";
export let matrixViewDotsLabel = "";
export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let connectActionLabel = "";
export let disconnectActionLabel = "";
export let exportActionLabel = "";
@@ -46,6 +51,7 @@
windowcontrol: WindowControlAction;
localechange: LocaleCode;
configlink: string;
matrixdisplaytoggle: boolean;
portchange: string;
serialrefresh: void;
serialconnect: string;
@@ -89,6 +95,10 @@
dispatch("configlink", linkId);
}
function emitMatrixDisplayToggle(): void {
dispatch("matrixdisplaytoggle", matrixDisplayMode !== "dots");
}
function emitPortChange(event: Event): void {
const target = event.currentTarget as HTMLSelectElement;
dispatch("portchange", target.value);
@@ -175,6 +185,24 @@
{/each}
</section>
<section class="matrix-switch-wrap" aria-label={matrixViewLabel}>
<span class="matrix-switch-label">{matrixViewLabel}</span>
<button
type="button"
class="matrix-switch-btn"
class:is-active={matrixDisplayMode === "dots"}
role="switch"
aria-checked={matrixDisplayMode === "dots"}
aria-label={matrixViewDotsLabel}
on:click={emitMatrixDisplayToggle}
>
<span class="matrix-switch-track" aria-hidden="true">
<span class="matrix-switch-thumb"></span>
</span>
<span class="matrix-switch-copy">{matrixDisplayMode === "dots" ? matrixViewDotsLabel : matrixViewNumericLabel}</span>
</button>
</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>
@@ -432,6 +460,108 @@
background: var(--panel-surface);
}
.matrix-switch-wrap {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-block-size: 2rem;
border: 1px solid var(--panel-line);
border-radius: 999px;
padding: 0.16rem 0.22rem 0.16rem 0.56rem;
background: var(--panel-surface);
}
.matrix-switch-label {
color: var(--panel-text-dim);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
white-space: nowrap;
}
.matrix-switch-btn {
display: inline-flex;
align-items: center;
gap: 0.42rem;
min-block-size: 1.62rem;
border: 1px solid rgb(var(--hud-border-rgb) / 0.26);
border-radius: 999px;
padding: 0.18rem 0.28rem 0.18rem 0.22rem;
background: rgb(var(--hud-surface-deep-rgb) / 0.84);
color: rgb(var(--hud-text-main-rgb) / 0.92);
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background-color 180ms ease,
color 180ms ease;
}
.matrix-switch-btn:hover {
border-color: rgb(var(--hud-cyan-rgb) / 0.4);
}
.matrix-switch-btn.is-active {
border-color: rgb(var(--hud-cyan-rgb) / 0.5);
background:
linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.94), rgb(var(--hud-surface-rgb) / 0.9)),
radial-gradient(circle at 50% 0, rgb(var(--hud-cyan-rgb) / 0.12), transparent 60%);
box-shadow:
inset 0 0 0 1px rgb(var(--hud-text-main-rgb) / 0.05),
0 0 12px rgb(var(--hud-cyan-rgb) / 0.12);
}
.matrix-switch-track {
position: relative;
display: inline-flex;
align-items: center;
inline-size: 2.2rem;
block-size: 1.2rem;
border-radius: 999px;
padding: 0.14rem;
background: rgb(var(--hud-surface-rgb) / 0.9);
box-shadow: inset 0 0 0 1px rgb(var(--hud-border-rgb) / 0.24);
transition:
background-color 180ms ease,
box-shadow 180ms ease;
}
.matrix-switch-btn.is-active .matrix-switch-track {
background: rgb(var(--hud-cyan-rgb) / 0.18);
box-shadow: inset 0 0 0 1px rgb(var(--hud-cyan-rgb) / 0.18);
}
.matrix-switch-thumb {
inline-size: 0.92rem;
block-size: 0.92rem;
border-radius: 50%;
background: rgb(var(--hud-text-main-rgb) / 0.96);
box-shadow:
0 1px 4px rgb(0 0 0 / 0.26),
0 0 10px rgb(var(--hud-text-main-rgb) / 0.12);
transform: translateX(0);
transition:
transform 180ms ease,
background-color 180ms ease,
box-shadow 180ms ease;
}
.matrix-switch-btn.is-active .matrix-switch-thumb {
transform: translateX(0.96rem);
background: rgb(var(--hud-cyan-rgb) / 0.96);
box-shadow:
0 1px 4px rgb(0 0 0 / 0.26),
0 0 12px rgb(var(--hud-cyan-rgb) / 0.22);
}
.matrix-switch-copy {
font-size: 0.74rem;
letter-spacing: 0.04em;
white-space: nowrap;
line-height: 1;
}
.state-dot {
inline-size: 0.55rem;
block-size: 0.55rem;

View File

@@ -3,12 +3,12 @@
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";
import type { HudSummary, MatrixDisplayMode, PressureColorMapPreset } from "$lib/types/hud";
interface ViewerStats {
total: number;
max: number;
avg: number;
current: number | null;
max: number | null;
min: number | null;
}
interface MatrixLayout {
@@ -28,12 +28,14 @@
export let rangeMin = 0;
export let rangeMax = 16000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let summary: HudSummary | null = null;
export let showStatsPanel = true;
let viewerEl: HTMLDivElement | undefined;
let canvasEl: HTMLCanvasElement | undefined;
let overlayEl: HTMLCanvasElement | undefined;
let stats: ViewerStats = { total: 0, max: 0, avg: 0 };
let stats: ViewerStats = { current: null, max: null, min: null };
const DEFAULT_RANGE_MAX = 16000;
const BASE_MATRIX_SPAN = 24;
@@ -63,6 +65,7 @@
const CAMERA_TARGET_Y = MATRIX_OFFSET_Y + 0.2;
const CAMERA_TARGET_Z = MATRIX_OFFSET_Z - 0.4;
const MATRIX_ROTATION_Y = 0;
const rangeStopPositions = [0, 0.25, 0.5, 0.75, 0.875, 1] as const;
const labelVector = new THREE.Vector3();
$: resolvedColorPalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
@@ -75,6 +78,7 @@
$: labelLowColor = new THREE.Color(resolvedColorPalette.labelLow);
$: labelMidColor = new THREE.Color(resolvedColorPalette.labelMid);
$: labelHighColor = new THREE.Color(resolvedColorPalette.labelHigh);
$: rangeStopColors = resolvedColorPalette.rangeStops.map((stop) => new THREE.Color(stop));
$: sceneClearColor = new THREE.Color(resolvedColorPalette.uiTheme.bg30);
$: sceneBoardColor = new THREE.Color(resolvedColorPalette.uiTheme.bg20);
$: sceneGridCenterColor = new THREE.Color(resolvedColorPalette.surfaceMid);
@@ -84,7 +88,7 @@
$: sceneAccentLightColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
$: surfaceThemeAccentColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
$: labelThemeAccentColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
$: labelHighlightCss = colorToCss(surfaceHotColor);
$: labelHighlightCss = colorToCss(rangeStopColors[5] ?? surfaceHotColor);
$: viewerThemeStyle = [
`--matrix-bg-10: ${resolvedColorPalette.uiTheme.bg10}`,
`--matrix-bg-20: ${resolvedColorPalette.uiTheme.bg20}`,
@@ -124,7 +128,16 @@
$: resolvedRangeMin = resolvedRange.min;
$: resolvedRangeMax = resolvedRange.max;
$: matrixLayout = buildMatrixLayout(resolvedMatrixRows, resolvedMatrixCols);
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / color range ${resolvedRangeMin}-${resolvedRangeMax} / label raw`;
$: statsModeLabel = matrixDisplayMode === "dots" ? "dot pulse" : "numeric pulse";
$: statsNote = `${resolvedMatrixRows}x${resolvedMatrixCols} / force range ${resolvedRangeMin}-${resolvedRangeMax} / ${statsModeLabel}`;
function formatForceStat(value: number | null): string {
if (value == null || !Number.isFinite(value)) {
return "--";
}
return value.toFixed(1);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
@@ -143,23 +156,26 @@
return new THREE.Color(`rgb(${rgbTriplet.replace(/\s+/g, ", ")})`);
}
function surfaceColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
function sampleRangeStopColor(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);
for (let index = 0; index < rangeStopPositions.length - 1; index += 1) {
const start = rangeStopPositions[index];
const end = rangeStopPositions[index + 1];
if (value <= end) {
const localT = smoothstep(start, end, value);
return target.copy(rangeStopColors[index]).lerp(rangeStopColors[index + 1], localT);
}
}
const baseAccentStrength = (1 - smoothstep(0.12, 0.52, value)) * 0.34;
const highlightStrength = smoothstep(0.82, 1, value) * 0.3;
return target.copy(rangeStopColors[rangeStopColors.length - 1]);
}
function surfaceColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
const value = clamp(valueNormalized, 0, 1);
const mapped = sampleRangeStopColor(value, target);
const baseAccentStrength = (1 - smoothstep(0.08, 0.28, value)) * 0.16;
const highlightStrength = smoothstep(0.88, 1, value) * 0.2;
return mapped.lerp(surfaceThemeAccentColor, baseAccentStrength).lerp(surfaceHotColor, highlightStrength);
}
@@ -171,22 +187,10 @@
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 baseAccentStrength = (1 - smoothstep(0.16, 0.58, value)) * 0.46;
const highlightStrength = smoothstep(0.8, 1, value) * 0.36;
return mapped.lerp(labelThemeAccentColor, baseAccentStrength).lerp(surfaceHotColor, highlightStrength);
const mapped = sampleRangeStopColor(value, target);
const baseAccentStrength = (1 - smoothstep(0.08, 0.24, value)) * 0.18;
const highlightStrength = smoothstep(0.88, 1, value) * 0.12;
return mapped.lerp(labelThemeAccentColor, baseAccentStrength).lerp(labelHighColor, highlightStrength);
}
function shapeHeightValue(valueNormalized: number): number {
@@ -263,6 +267,24 @@
return `rgb(${Math.round(color.r * 255)} ${Math.round(color.g * 255)} ${Math.round(color.b * 255)})`;
}
function drawProjectedDot(
context: CanvasRenderingContext2D,
screenX: number,
screenY: number,
radius: number,
fillStyle: string,
glowStyle: string,
opacity: number
): void {
context.globalAlpha = opacity;
context.shadowBlur = radius * 2.8;
context.shadowColor = glowStyle;
context.fillStyle = fillStyle;
context.beginPath();
context.arc(screenX, screenY, radius, 0, Math.PI * 2);
context.fill();
}
$: labelPalette = Array.from({ length: 33 }, (_, index) => {
const t = index / 32;
return colorToCss(labelColorMap(t, new THREE.Color()));
@@ -431,7 +453,7 @@
const compactField = new Uint16Array(instanceCount);
let lastFrameAt = performance.now();
const drawNumberOverlay = () => {
const drawOverlay = () => {
if (!viewerEl || !overlayEl) {
return;
}
@@ -464,10 +486,42 @@
const normalized = normalizedField[index];
const displayValue = compactField[index];
const bucket = Math.min(32, Math.round(normalized * 32));
const isDotsMode = matrixDisplayMode === "dots";
if (isDotsMode) {
const baseDotRadius = clamp(cellSpacing * 0.48, 7.2, 21.6);
const dotRadius = clamp(baseDotRadius + smoothstep(0, 1, normalized) * (cellSpacing * 0.86 + 9.6), 7.2, 15);
const dotOpacity = displayValue === 0 ? 0.62 : 0.98;
drawProjectedDot(
overlayContext,
screenX,
screenY,
dotRadius,
labelPalette[bucket],
labelGlowPalette[bucket],
dotOpacity
);
if (normalized >= 0.8) {
drawProjectedDot(
overlayContext,
screenX,
screenY,
dotRadius * 0.46,
labelHighlightCss,
labelHighlightCss,
smoothstep(0.8, 1, normalized) * 0.42
);
}
continue;
}
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;
@@ -476,7 +530,6 @@
overlayContext.font = `600 ${glyphSize.toFixed(1)}px "JetBrains Mono", "IBM Plex Mono", "Consolas", monospace`;
overlayContext.shadowBlur = glowBlur;
overlayContext.shadowColor = labelGlowPalette[bucket];
overlayContext.fillStyle = labelPalette[bucket];
overlayContext.globalAlpha = displayValue === 0 ? 0.86 : 1;
overlayContext.fillText(displayText, screenX, screenY);
@@ -550,9 +603,6 @@
}
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);
@@ -560,20 +610,15 @@
heightField[index] = height;
compactField[index] = compactDisplayValue(smoothedField[index], resolvedRangeMin, resolvedRangeMax);
total += smoothedField[index];
if (smoothedField[index] > 30) {
activeCount += 1;
}
}
renderer.render(scene, camera);
drawNumberOverlay();
drawOverlay();
stats = {
total,
max: maxValue,
avg: activeCount > 0 ? total / activeCount : 0
current: summary?.latest ?? null,
max: summary?.max ?? null,
min: summary?.min ?? null
};
});
@@ -612,19 +657,19 @@
{#if showStatsPanel}
<div class="viewer-controls">
<section class="stats-panel" aria-label="Pressure Summary">
<p class="stats-label">Pressure Matrix</p>
<p class="stats-label">Resultant Force</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>
<span class="stats-key">Current RF</span>
<strong class="stats-value">{formatForceStat(stats.current)}</strong>
</article>
<article class="stats-card">
<span class="stats-key">Max</span>
<strong class="stats-value">{stats.max.toFixed(0)}</strong>
<span class="stats-key">Max RF</span>
<strong class="stats-value">{formatForceStat(stats.max)}</strong>
</article>
<article class="stats-card">
<span class="stats-key">Avg</span>
<strong class="stats-value">{stats.avg.toFixed(0)}</strong>
<span class="stats-key">Min RF</span>
<strong class="stats-value">{formatForceStat(stats.min)}</strong>
</article>
</div>
<p class="stats-note">{statsNote}</p>

View File

@@ -7,10 +7,13 @@
export let xValues: number[] | null = null;
export let yValues: number[] | null = null;
const viewportWidth = 100;
const viewportHeight = 36;
const horizontalInset = 2;
const verticalInset = 2;
const viewportWidth = 120;
const viewportHeight = 48;
const plotInsetLeft = 13;
const plotInsetRight = 4;
const plotInsetTop = 4;
const plotInsetBottom = 9;
const fixedYBounds = { min: 0, max: 25 };
interface CurveSample {
x: number;
@@ -50,12 +53,7 @@
return String(Math.round(value));
}
if (Math.abs(value) >= 1000) {
const compact = Math.round((value / 1000) * 10) / 10;
return Number.isInteger(compact) ? `${compact.toFixed(0)}k` : `${compact.toFixed(1)}k`;
}
return Math.abs(value) >= 100 ? Math.round(value).toString() : value.toFixed(1);
return `${Math.round(value)} N`;
}
function resolveDataBounds(values: number[]): { min: number; max: number } {
@@ -87,18 +85,18 @@
function mapXToViewport(value: number, bounds: { min: number; max: number }): number {
const span = bounds.max - bounds.min;
const chartWidth = viewportWidth - horizontalInset * 2;
const chartWidth = viewportWidth - plotInsetLeft - plotInsetRight;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedX = horizontalInset + ratio * chartWidth;
return Math.round(clamp(mappedX, horizontalInset, viewportWidth - horizontalInset) * 100) / 100;
const mappedX = plotInsetLeft + ratio * chartWidth;
return Math.round(clamp(mappedX, plotInsetLeft, viewportWidth - plotInsetRight) * 100) / 100;
}
function mapYToViewport(value: number, bounds: { min: number; max: number }): number {
const span = bounds.max - bounds.min;
const chartHeight = viewportHeight - verticalInset * 2;
const chartHeight = viewportHeight - plotInsetTop - plotInsetBottom;
const ratio = span <= 0 ? 0.5 : (value - bounds.min) / span;
const mappedY = viewportHeight - verticalInset - ratio * chartHeight;
return Math.round(clamp(mappedY, verticalInset, viewportHeight - verticalInset) * 100) / 100;
const mappedY = viewportHeight - plotInsetBottom - ratio * chartHeight;
return Math.round(clamp(mappedY, plotInsetTop, viewportHeight - plotInsetBottom) * 100) / 100;
}
function buildSamples(rawYValues: number[], rawXValues: number[]): CurveSample[] {
@@ -137,16 +135,13 @@
function buildYAxisTicks(
yScaleBounds: { min: number; max: number },
yDataBounds: { min: number; max: number }
_yDataBounds: { min: number; max: number }
): AxisTick[] {
const hasRange = Math.abs(yDataBounds.max - yDataBounds.min) >= 0.001;
const tickValues = hasRange
? [yDataBounds.max, (yDataBounds.max + yDataBounds.min) / 2, yDataBounds.min]
: [yScaleBounds.max, (yScaleBounds.max + yScaleBounds.min) / 2, yScaleBounds.min];
const tickValues = [25, 20, 15, 10, 5, 0];
return tickValues.map((value) => ({
value,
label: formatAxisValue(value, "y"),
plotX: horizontalInset,
plotX: plotInsetLeft - 1.8,
plotY: mapYToViewport(value, yScaleBounds)
}));
}
@@ -164,7 +159,7 @@
value,
label: formatAxisValue(value, "x"),
plotX: mapXToViewport(value, xScaleBounds),
plotY: viewportHeight - 1.2
plotY: viewportHeight - 0.9
}));
}
@@ -185,7 +180,7 @@
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
return `${linePath} L ${lastPoint.x} ${viewportHeight} L ${firstPoint.x} ${viewportHeight} Z`;
return `${linePath} L ${lastPoint.x} ${viewportHeight - plotInsetBottom} L ${firstPoint.x} ${viewportHeight - plotInsetBottom} Z`;
}
$: sourceYValues = yValues && yValues.length ? yValues : summary.points;
@@ -193,7 +188,7 @@
$: samples = buildSamples(sourceYValues, sourceXValues);
$: sampleCount = samples.length;
$: xScaleBounds = resolveBounds(samples.map((sample) => sample.x));
$: yScaleBounds = resolveBounds(samples.map((sample) => sample.y));
$: yScaleBounds = fixedYBounds;
$: xDataBounds = resolveDataBounds(samples.map((sample) => sample.x));
$: yDataBounds = resolveDataBounds(samples.map((sample) => sample.y));
$: plotPoints = convertPoints(samples, xScaleBounds, yScaleBounds);
@@ -215,7 +210,7 @@
>
<header class="panel-head">
<div class="head-text">
<p class="panel-code">TOT</p>
<p class="panel-code">RF</p>
<p class="panel-title">{summary.label}</p>
</div>
@@ -236,8 +231,8 @@
</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 yAxisTicks as tick (`grid-${tick.value}`)}
<line x1={plotInsetLeft} y1={tick.plotY} x2={viewportWidth - plotInsetRight} y2={tick.plotY}></line>
{/each}
</g>
@@ -255,7 +250,7 @@
<g class="axis-labels" aria-hidden="true">
{#each yAxisTicks as tick, index (`y-${index}`)}
<text class="axis-label y-axis-label" x={tick.plotX + 0.8} y={tick.plotY - 0.35} text-anchor="start">
<text class="axis-label y-axis-label" x={tick.plotX} y={tick.plotY + 1.1} text-anchor="end">
{tick.label}
</text>
{/each}
@@ -305,14 +300,14 @@
--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;
inline-size: min(100%, clamp(29rem, 38vw, 37rem));
aspect-ratio: 1.42 / 1;
min-block-size: 20.5rem;
justify-self: start;
display: grid;
grid-template-rows: auto auto auto;
gap: 0.4rem;
padding: 0.56rem 0.62rem 0.58rem;
gap: 0.68rem;
padding: 0.88rem 0.96rem 1rem;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.42);
border-radius: 0.92rem;
background:
@@ -345,6 +340,10 @@
opacity: 0.82;
}
.summary-panel {
margin-block-end: clamp(0.8rem, 1.8vh, 1.4rem);
}
.panel-head {
display: flex;
justify-content: space-between;
@@ -367,7 +366,7 @@
.panel-title {
margin: 0.12rem 0 0;
font-size: 0.75rem;
font-size: 1.08rem;
color: rgb(var(--hud-text-main-rgb) / 0.96);
letter-spacing: 0.05em;
}
@@ -404,7 +403,7 @@
.chart-stage {
position: relative;
block-size: clamp(6.4rem, 9vw, 8.2rem);
block-size: clamp(12rem, 15.5vw, 15rem);
overflow: hidden;
border: 1px solid rgb(var(--hud-border-strong-rgb) / 0.32);
border-radius: 0.62rem;
@@ -444,8 +443,8 @@
.axis-label {
fill: rgb(var(--hud-text-main-rgb) / 0.88);
font-size: 2.8px;
font-weight: 500;
font-size: 3.2px;
font-weight: 600;
letter-spacing: 0.02em;
text-shadow:
0 1px 0 rgb(0 0 0 / 0.46),
@@ -487,7 +486,7 @@
align-items: center;
gap: 0.28rem;
color: rgb(var(--hud-text-main-rgb) / 0.9);
font-size: 0.62rem;
font-size: 0.76rem;
letter-spacing: 0.04em;
}
@@ -520,28 +519,28 @@
@media (max-width: 1180px) {
.signal-panel {
inline-size: min(100%, clamp(14rem, 30vw, 17rem));
aspect-ratio: 1.5 / 1;
min-block-size: 10.1rem;
inline-size: min(100%, clamp(24rem, 36vw, 31rem));
aspect-ratio: 1.48 / 1;
min-block-size: 17rem;
}
}
@media (max-height: 900px) {
.signal-panel {
inline-size: min(100%, clamp(15rem, 22vw, 18.5rem));
min-block-size: 10.6rem;
inline-size: min(100%, clamp(24rem, 33vw, 30rem));
min-block-size: 16.8rem;
}
.chart-stage {
block-size: clamp(5.7rem, 7.6vw, 6.9rem);
block-size: clamp(9.8rem, 12vw, 11.8rem);
}
}
@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;
inline-size: min(100%, clamp(21rem, 29vw, 26rem));
min-block-size: 14.4rem;
padding: 0.7rem 0.76rem 0.8rem;
}
.panel-foot {
@@ -549,15 +548,15 @@
}
.chart-stage {
block-size: clamp(5rem, 6.6vw, 6rem);
block-size: clamp(8.3rem, 9.6vw, 9.8rem);
}
}
@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;
inline-size: min(100%, clamp(18.5rem, 24vw, 22.5rem));
min-block-size: 12.4rem;
padding: 0.62rem 0.66rem 0.68rem;
}
.panel-head {
@@ -570,7 +569,7 @@
}
.chart-stage {
block-size: clamp(4.4rem, 5.6vw, 5.4rem);
block-size: clamp(7rem, 7.8vw, 8rem);
}
}

View File

@@ -35,17 +35,17 @@ export interface PressureColorPalette {
export const pressureColorPalettes: Record<PressureColorMapPreset, PressureColorPalette> = {
emerald: {
surfaceBase: "#13201a",
surfaceLow: "#285338",
surfaceMid: "#3f8a66",
surfaceHigh: "#6dd3ad",
surfaceBase: "#397557",
surfaceLow: "#24563a",
surfaceMid: "#2f8d78",
surfaceHigh: "#62d9cf",
surfaceHot: "#d9fff0",
labelZero: "#2d8d59",
labelLow: "#54df8e",
labelMid: "#98e6ff",
labelHigh: "#ffab78",
rangeStops: ["#13201a", "#285338", "#3f8a66", "#6dd3ad", "#98e6ff", "#ffab78"],
rangeGlow: ["#54df8e", "#98e6ff", "#ffab78"],
labelZero: "#88e3ac",
labelLow: "#52e6a0",
labelMid: "#5dcfff",
labelHigh: "#ff5a4f",
rangeStops: ["#397557", "#36c06d", "#59cfff", "#ffd85a", "#ff8d4d", "#ff5247"],
rangeGlow: ["#52e6a0", "#59cfff", "#ff5247"],
uiTheme: {
bg00: "#020403",
bg10: "#07100d",

View File

@@ -21,12 +21,12 @@
--hud-glow-alt-rgb: 133 255 68;
--hud-text-main-rgb: 207 231 255;
--hud-text-dim-rgb: 134 162 184;
--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-range-0: #397557;
--hud-range-1: #36c06d;
--hud-range-2: #59cfff;
--hud-range-3: #ffd85a;
--hud-range-4: #ff8d4d;
--hud-range-5: #ff5247;
--hud-text-main: #cfe7ff;
--hud-text-dim: #86a2b8;

View File

@@ -9,6 +9,7 @@ export type HudNoticeTone = "ok" | "warn" | "info";
export type SignalTone = "cyan" | "lime" | "orange" | "violet" | "gold" | "rose";
export type PressureColorMapPreset = "emerald" | "arctic" | "ember";
export type MatrixDisplayMode = "numeric" | "dots";
export type SignalPanelSide = "left" | "right";
@@ -82,6 +83,9 @@ export interface HudCopy {
rangeMinLabel: string;
rangeMaxLabel: string;
colorMapLabel: string;
matrixViewLabel: string;
matrixViewNumericLabel: string;
matrixViewDotsLabel: string;
resetConfigLabel: string;
applyLiveHint: string;
runtimeReady: string;
@@ -131,6 +135,7 @@ export interface HudMatrixConfig {
rangeMin: number;
rangeMax: number;
colorMapPreset: PressureColorMapPreset;
matrixDisplayMode: MatrixDisplayMode;
}
export interface SerialConnectResult {