first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user