771 lines
27 KiB
Svelte
771 lines
27 KiB
Svelte
<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 = 16000;
|
|
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
|
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 };
|
|
|
|
const DEFAULT_RANGE_MAX = 16000;
|
|
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 = 10.6;
|
|
const BASE_HEIGHT = 0.12;
|
|
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();
|
|
$: 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);
|
|
$: sceneClearColor = new THREE.Color(resolvedColorPalette.uiTheme.bg30);
|
|
$: sceneBoardColor = new THREE.Color(resolvedColorPalette.uiTheme.bg20);
|
|
$: sceneGridCenterColor = new THREE.Color(resolvedColorPalette.surfaceMid);
|
|
$: sceneGridLineColor = new THREE.Color(resolvedColorPalette.uiTheme.bg20);
|
|
$: sceneAmbientLightColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.textMainRgb);
|
|
$: sceneKeyLightColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowAltRgb);
|
|
$: sceneAccentLightColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
|
|
$: surfaceThemeAccentColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
|
|
$: labelThemeAccentColor = rgbTripletToThreeColor(resolvedColorPalette.uiTheme.glowRgb);
|
|
$: labelHighlightCss = colorToCss(surfaceHotColor);
|
|
$: viewerThemeStyle = [
|
|
`--matrix-bg-10: ${resolvedColorPalette.uiTheme.bg10}`,
|
|
`--matrix-bg-20: ${resolvedColorPalette.uiTheme.bg20}`,
|
|
`--matrix-bg-30: ${resolvedColorPalette.uiTheme.bg30}`,
|
|
`--matrix-text-main-rgb: ${resolvedColorPalette.uiTheme.textMainRgb}`,
|
|
`--matrix-text-dim-rgb: ${resolvedColorPalette.uiTheme.textDimRgb}`,
|
|
`--matrix-border-rgb: ${resolvedColorPalette.uiTheme.borderRgb}`,
|
|
`--matrix-border-strong-rgb: ${resolvedColorPalette.uiTheme.borderStrongRgb}`,
|
|
`--matrix-surface-rgb: ${resolvedColorPalette.uiTheme.surfaceRgb}`,
|
|
`--matrix-surface-alt-rgb: ${resolvedColorPalette.uiTheme.surfaceAltRgb}`,
|
|
`--matrix-surface-deep-rgb: ${resolvedColorPalette.uiTheme.surfaceDeepRgb}`,
|
|
`--matrix-glow-rgb: ${resolvedColorPalette.uiTheme.glowRgb}`,
|
|
`--matrix-glow-alt-rgb: ${resolvedColorPalette.uiTheme.glowAltRgb}`
|
|
].join("; ");
|
|
|
|
let rendererRef: THREE.WebGLRenderer | null = null;
|
|
let boardMaterialRef: THREE.MeshBasicMaterial | null = null;
|
|
let gridRef: THREE.GridHelper | null = null;
|
|
let gridMaterialRef: THREE.Material | THREE.Material[] | null = null;
|
|
let ambientLightRef: THREE.AmbientLight | null = null;
|
|
let dirLightRef: THREE.DirectionalLight | null = null;
|
|
let sideLightRef: THREE.DirectionalLight | null = null;
|
|
|
|
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 : DEFAULT_RANGE_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 rgbTripletToThreeColor(rgbTriplet: string): THREE.Color {
|
|
return new THREE.Color(`rgb(${rgbTriplet.replace(/\s+/g, ", ")})`);
|
|
}
|
|
|
|
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 baseAccentStrength = (1 - smoothstep(0.12, 0.52, value)) * 0.34;
|
|
const highlightStrength = smoothstep(0.82, 1, value) * 0.3;
|
|
return mapped.lerp(surfaceThemeAccentColor, baseAccentStrength).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(surfaceHotColor, 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 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);
|
|
}
|
|
|
|
function shapeHeightValue(valueNormalized: number): number {
|
|
return Math.pow(clamp(valueNormalized, 0, 1), 0.9);
|
|
}
|
|
|
|
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.42, 0.36, 1.12);
|
|
|
|
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) {
|
|
const value = Number(values[index] ?? 0);
|
|
target[index] = Number.isFinite(value) ? value : 0;
|
|
}
|
|
}
|
|
|
|
function compactDisplayValue(rawValue: number, minValue: number, maxValue: number): number {
|
|
if (rawValue <= minValue + 4) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.round(rawValue);
|
|
}
|
|
|
|
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()));
|
|
});
|
|
$: labelGlowPalette = Array.from({ length: 33 }, (_, index) => {
|
|
const t = index / 32;
|
|
return colorToCss(glowColorMap(t, new THREE.Color()));
|
|
});
|
|
|
|
function applyGridTheme(grid: THREE.GridHelper, divisions: number): void {
|
|
const colorAttribute = grid.geometry.getAttribute("color");
|
|
if (!(colorAttribute instanceof THREE.BufferAttribute)) {
|
|
return;
|
|
}
|
|
|
|
for (let division = 0; division <= divisions; division += 1) {
|
|
const lineColor = division === divisions / 2 ? sceneGridCenterColor : sceneGridLineColor;
|
|
const vertexBase = division * 4;
|
|
|
|
for (let vertexOffset = 0; vertexOffset < 4; vertexOffset += 1) {
|
|
colorAttribute.setXYZ(vertexBase + vertexOffset, lineColor.r, lineColor.g, lineColor.b);
|
|
}
|
|
}
|
|
|
|
colorAttribute.needsUpdate = true;
|
|
}
|
|
|
|
function applySceneTheme(): void {
|
|
if (!rendererRef || !boardMaterialRef || !gridRef || !gridMaterialRef) {
|
|
return;
|
|
}
|
|
|
|
rendererRef.setClearColor(sceneClearColor, 1);
|
|
boardMaterialRef.color.copy(sceneBoardColor);
|
|
boardMaterialRef.needsUpdate = true;
|
|
applyGridTheme(gridRef, matrixLayout.gridDivisions);
|
|
|
|
if (Array.isArray(gridMaterialRef)) {
|
|
for (const material of gridMaterialRef) {
|
|
material.transparent = true;
|
|
material.opacity = 0.034;
|
|
material.needsUpdate = true;
|
|
}
|
|
} else {
|
|
gridMaterialRef.transparent = true;
|
|
gridMaterialRef.opacity = 0.034;
|
|
gridMaterialRef.needsUpdate = true;
|
|
}
|
|
|
|
ambientLightRef?.color.copy(sceneAmbientLightColor);
|
|
dirLightRef?.color.copy(sceneKeyLightColor);
|
|
sideLightRef?.color.copy(sceneAccentLightColor);
|
|
}
|
|
|
|
$: applySceneTheme();
|
|
|
|
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"
|
|
});
|
|
rendererRef = renderer;
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.setClearColor(sceneClearColor, 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(sceneAmbientLightColor, 0.26);
|
|
const dirLight = new THREE.DirectionalLight(sceneKeyLightColor, 0.34);
|
|
dirLight.position.set(50, 100, 50);
|
|
const sideLight = new THREE.DirectionalLight(sceneAccentLightColor, 0.16);
|
|
sideLight.position.set(-50, 50, -50);
|
|
ambientLightRef = ambientLight;
|
|
dirLightRef = dirLight;
|
|
sideLightRef = sideLight;
|
|
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: sceneBoardColor,
|
|
transparent: true,
|
|
opacity: 0.12,
|
|
toneMapped: false
|
|
})
|
|
);
|
|
boardMaterialRef = board.material;
|
|
board.rotation.x = -Math.PI / 2;
|
|
board.position.y = -0.04;
|
|
matrixGroup.add(board);
|
|
|
|
const grid = new THREE.GridHelper(gridSpan, gridDivisions, sceneGridCenterColor, sceneGridLineColor);
|
|
gridRef = grid;
|
|
grid.position.y = 0;
|
|
const gridMaterial = grid.material;
|
|
gridMaterialRef = gridMaterial;
|
|
if (Array.isArray(gridMaterial)) {
|
|
for (const material of gridMaterial) {
|
|
material.transparent = true;
|
|
material.opacity = 0.034;
|
|
}
|
|
} else {
|
|
gridMaterial.transparent = true;
|
|
gridMaterial.opacity = 0.034;
|
|
}
|
|
matrixGroup.add(grid);
|
|
applySceneTheme();
|
|
|
|
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 = labelGlowPalette[bucket];
|
|
|
|
overlayContext.fillStyle = labelPalette[bucket];
|
|
overlayContext.globalAlpha = displayValue === 0 ? 0.86 : 1;
|
|
overlayContext.fillText(displayText, screenX, screenY);
|
|
|
|
if (normalized >= 0.8) {
|
|
overlayContext.fillStyle = labelHighlightCss;
|
|
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();
|
|
rendererRef = null;
|
|
boardMaterialRef = null;
|
|
gridRef = null;
|
|
gridMaterialRef = null;
|
|
ambientLightRef = null;
|
|
dirLightRef = null;
|
|
sideLightRef = null;
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="viewer-root" bind:this={viewerEl} style={viewerThemeStyle}>
|
|
<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>
|
|
|
|
{#if showStatsPanel}
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.viewer-root {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
background:
|
|
radial-gradient(circle at 50% 58%, rgb(var(--matrix-glow-rgb) / 0.11), transparent 32%),
|
|
radial-gradient(circle at 50% 12%, rgb(var(--matrix-glow-alt-rgb) / 0.09), transparent 26%),
|
|
linear-gradient(180deg, color-mix(in srgb, var(--matrix-bg-10) 84%, transparent), color-mix(in srgb, var(--matrix-bg-30) 96%, black 4%));
|
|
}
|
|
|
|
.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(var(--matrix-glow-alt-rgb) / 0.025) 0,
|
|
rgb(var(--matrix-glow-alt-rgb) / 0.025) 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(var(--matrix-border-rgb) / 0.32);
|
|
border-radius: 0.76rem;
|
|
background: linear-gradient(180deg, rgb(var(--matrix-surface-alt-rgb) / 0.92), rgb(var(--matrix-surface-deep-rgb) / 0.86));
|
|
box-shadow:
|
|
inset 0 1px 0 rgb(var(--matrix-border-strong-rgb) / 0.08),
|
|
0 0 24px rgb(var(--matrix-glow-rgb) / 0.08);
|
|
}
|
|
|
|
.stats-label,
|
|
.stats-key,
|
|
.stats-note {
|
|
margin: 0;
|
|
color: rgb(var(--matrix-text-dim-rgb) / 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(var(--matrix-border-rgb) / 0.24);
|
|
border-radius: 0.56rem;
|
|
background: linear-gradient(180deg, rgb(var(--matrix-surface-rgb) / 0.9), rgb(var(--matrix-surface-deep-rgb) / 0.86));
|
|
}
|
|
|
|
.stats-card-wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.stats-value {
|
|
color: rgb(var(--matrix-text-main-rgb) / 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>
|