feat:themes, tactilea codec

This commit is contained in:
lenn
2026-04-03 16:40:48 +08:00
parent 7688986ad7
commit 1c5ac13da8
42 changed files with 881 additions and 551 deletions

View File

@@ -26,7 +26,7 @@
export let matrixRows = 12;
export let matrixCols = 7;
export let rangeMin = 0;
export let rangeMax = 5000;
export let rangeMax = 16000;
export let colorMapPreset: PressureColorMapPreset = "emerald";
let viewerEl: HTMLDivElement | undefined;
@@ -34,7 +34,7 @@
let overlayEl: HTMLCanvasElement | undefined;
let stats: ViewerStats = { total: 0, max: 0, avg: 0 };
const RAW_DATA_MAX = 5000;
const DEFAULT_RANGE_MAX = 16000;
const BASE_MATRIX_SPAN = 24;
const MATRIX_SPAN_GROWTH = 0.6;
const MIN_MATRIX_SPAN = 24;
@@ -64,7 +64,6 @@
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);
@@ -75,6 +74,38 @@
$: 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);
@@ -82,7 +113,7 @@
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);
const resolvedMax = Math.max(Math.round(Number.isFinite(maxValue) ? maxValue : DEFAULT_RANGE_MAX), resolvedMin + 1);
return { min: resolvedMin, max: resolvedMax };
}
@@ -107,6 +138,10 @@
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;
@@ -122,14 +157,15 @@
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(surfaceHotColor, highlightStrength);
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(whiteColor, glowStrength * 0.42);
return surfaceColorMap(value, target).lerp(surfaceHotColor, glowStrength * 0.42);
}
function labelColorMap(valueNormalized: number, target: THREE.Color = new THREE.Color()): THREE.Color {
@@ -147,8 +183,9 @@
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(whiteColor, highlightStrength);
return mapped.lerp(labelThemeAccentColor, baseAccentStrength).lerp(surfaceHotColor, highlightStrength);
}
function shapeHeightValue(valueNormalized: number): number {
@@ -208,7 +245,8 @@
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);
const value = Number(values[index] ?? 0);
target[index] = Number.isFinite(value) ? value : 0;
}
}
@@ -217,7 +255,7 @@
return 0;
}
return Math.round(clamp(rawValue, 0, Math.max(maxValue, RAW_DATA_MAX)));
return Math.round(rawValue);
}
function colorToCss(color: THREE.Color): string {
@@ -228,6 +266,57 @@
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) {
@@ -251,8 +340,9 @@
alpha: true,
powerPreference: "high-performance"
});
rendererRef = renderer;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x06080a, 1);
renderer.setClearColor(sceneClearColor, 1);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
@@ -277,11 +367,14 @@
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);
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(0x7cffba, 0.16);
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();
@@ -292,29 +385,33 @@
const board = new THREE.Mesh(
new THREE.PlaneGeometry(boardWidth + boardPadding * 2, boardDepth + boardPadding * 2),
new THREE.MeshBasicMaterial({
color: 0x05070a,
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, 0x1a2630, 0x0a1015);
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.028;
material.opacity = 0.034;
}
} else {
gridMaterial.transparent = true;
gridMaterial.opacity = 0.028;
gridMaterial.opacity = 0.034;
}
matrixGroup.add(grid);
applySceneTheme();
const cellX = new Float32Array(instanceCount);
const cellZ = new Float32Array(instanceCount);
@@ -377,14 +474,14 @@
overlayContext.font = `600 ${glyphSize.toFixed(1)}px "JetBrains Mono", "IBM Plex Mono", "Consolas", monospace`;
overlayContext.shadowBlur = glowBlur;
overlayContext.shadowColor = labelPalette[bucket];
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 = "rgb(255 245 220)";
overlayContext.fillStyle = labelHighlightCss;
overlayContext.globalAlpha = smoothstep(0.8, 1, normalized) * 0.34;
overlayContext.fillText(displayText, screenX, screenY);
}
@@ -493,11 +590,18 @@
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}>
<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>
@@ -532,9 +636,9 @@
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));
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,
@@ -563,7 +667,14 @@
}
.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);
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 {
@@ -580,17 +691,19 @@
display: grid;
gap: 0.58rem;
padding: 0.74rem 0.84rem 0.82rem;
border: 1px solid rgb(86 151 118 / 0.32);
border: 1px solid rgb(var(--matrix-border-rgb) / 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);
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(165 212 187 / 0.84);
color: rgb(var(--matrix-text-dim-rgb) / 0.84);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
@@ -607,9 +720,9 @@
gap: 0.24rem;
min-height: 4.2rem;
padding: 0.58rem 0.64rem;
border: 1px solid rgb(71 122 96 / 0.24);
border: 1px solid rgb(var(--matrix-border-rgb) / 0.24);
border-radius: 0.56rem;
background: linear-gradient(180deg, rgb(8 16 14 / 0.88), rgb(5 9 8 / 0.84));
background: linear-gradient(180deg, rgb(var(--matrix-surface-rgb) / 0.9), rgb(var(--matrix-surface-deep-rgb) / 0.86));
}
.stats-card-wide {
@@ -617,7 +730,7 @@
}
.stats-value {
color: rgb(240 246 255 / 0.98);
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;