first commit

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

View File

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