/ concept-collection / surfacefun-interactive
Sign in
concept-collection / surfacefun-interactive
1115 lines · 34.3 KBBlameHistoryRaw
1import { useRef, useEffect, type CSSProperties } from "react";
2import * as THREE from "three";
3import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
4import { Line2 } from "three/examples/jsm/lines/Line2.js";
5import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
6import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
7import type {
8 SurfTrace,
9 Plot3Trace,
10 Bar3Trace,
11 Quiver3Trace,
12} from "./types.js";
13import { colormapLookup } from "./surfColormap.js";
15// Color order for plot3 traces
16const TRACE_COLORS = [
17 [0, 0.447, 0.741], // #0072BD blue
18 [0.85, 0.325, 0.098], // #D95319 red-orange
19 [0.929, 0.694, 0.125], // #EDB120 yellow
20 [0.494, 0.184, 0.556], // #7E2F8E purple
21 [0.466, 0.674, 0.188], // #77AC30 green
22 [0.301, 0.745, 0.933], // #4DBEEE cyan
23 [0.635, 0.078, 0.184], // #A2142F dark red
24];
26interface SurfViewProps {
27 surfTraces: SurfTrace[];
28 plot3Traces?: Plot3Trace[];
29 bar3Traces?: Bar3Trace[];
30 bar3hTraces?: Bar3Trace[];
31 quiver3Traces?: Quiver3Trace[];
32 shading?: "faceted" | "flat" | "interp";
33 colorbar?: boolean;
34 colorbarLocation?: string;
35 colormap?: string;
36 /** `axis off` hides the axes box/lines (the plotted surfaces remain). */
37 axisVisible?: boolean;
40export function SurfView({
41 surfTraces,
42 plot3Traces = [],
43 bar3Traces = [],
44 bar3hTraces = [],
45 quiver3Traces = [],
46 shading,
47 colorbar,
48 colorbarLocation,
49 colormap,
50 axisVisible,
51}: SurfViewProps) {
52 const containerRef = useRef<HTMLDivElement>(null);
53 const stateRef = useRef<{
54 renderer: THREE.WebGLRenderer;
55 scene: THREE.Scene;
56 camera: THREE.OrthographicCamera;
57 controls: OrbitControls;
58 animId: number;
59 } | null>(null);
61 // Set up the three.js scene once
62 useEffect(() => {
63 const container = containerRef.current;
64 if (!container) return;
66 const renderer = new THREE.WebGLRenderer({ antialias: true });
67 renderer.setPixelRatio(window.devicePixelRatio);
68 renderer.setClearColor(0xffffff);
69 container.appendChild(renderer.domElement);
71 const scene = new THREE.Scene();
73 // Orthographic camera — frustum will be sized on resize
74 const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100);
75 camera.position.set(1.2, 0.8, 1.2);
76 camera.lookAt(0, 0, 0);
78 const controls = new OrbitControls(camera, renderer.domElement);
79 controls.enablePan = false;
80 controls.enableZoom = true;
82 // Ambient + directional light
83 scene.add(new THREE.AmbientLight(0xffffff, 0.6));
84 const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
85 dirLight.position.set(2, 3, 2);
86 scene.add(dirLight);
88 const animId = requestAnimationFrame(function loop() {
89 controls.update();
90 renderer.render(scene, camera);
91 stateRef.current!.animId = requestAnimationFrame(loop);
92 });
94 stateRef.current = { renderer, scene, camera, controls, animId };
96 // Handle resize
97 const observer = new ResizeObserver(() => {
98 const rect = container.getBoundingClientRect();
99 if (rect.width === 0 || rect.height === 0) return;
100 renderer.setSize(rect.width, rect.height);
101 const aspect = rect.width / rect.height;
102 const frustumSize = 1.2;
103 camera.left = -frustumSize * aspect;
104 camera.right = frustumSize * aspect;
105 camera.top = frustumSize;
106 camera.bottom = -frustumSize;
107 camera.updateProjectionMatrix();
108 });
109 observer.observe(container);
111 return () => {
112 observer.disconnect();
113 cancelAnimationFrame(stateRef.current?.animId ?? animId);
114 controls.dispose();
115 renderer.dispose();
116 container.removeChild(renderer.domElement);
117 stateRef.current = null;
118 };
119 }, []);
121 // Rebuild scene when data changes
122 useEffect(() => {
123 const st = stateRef.current;
124 if (!st) return;
125 const { scene } = st;
127 // Remove old meshes/lines (keep lights)
128 const toRemove: THREE.Object3D[] = [];
129 scene.traverse(obj => {
130 if (
131 obj instanceof THREE.Mesh ||
132 obj instanceof THREE.LineSegments ||
133 obj instanceof THREE.Line
134 ) {
135 toRemove.push(obj);
136 }
137 });
138 for (const obj of toRemove) {
139 scene.remove(obj);
140 if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
141 }
143 if (
144 surfTraces.length === 0 &&
145 plot3Traces.length === 0 &&
146 bar3Traces.length === 0 &&
147 bar3hTraces.length === 0 &&
148 quiver3Traces.length === 0
149 )
150 return;
152 // Compute global data ranges across both surf and plot3 traces
153 let xMin = Infinity,
154 xMax = -Infinity;
155 let yMin = Infinity,
156 yMax = -Infinity;
157 let zMin = Infinity,
158 zMax = -Infinity;
160 const updateRange = (
161 arr: number[],
162 updateMin: { v: number },
163 updateMax: { v: number }
164 ) => {
165 for (const v of arr) {
166 if (isFinite(v)) {
167 if (v < updateMin.v) updateMin.v = v;
168 if (v > updateMax.v) updateMax.v = v;
169 }
170 }
171 };
173 const xMinRef = { v: xMin },
174 xMaxRef = { v: xMax };
175 const yMinRef = { v: yMin },
176 yMaxRef = { v: yMax };
177 const zMinRef = { v: zMin },
178 zMaxRef = { v: zMax };
180 for (const trace of surfTraces) {
181 updateRange(trace.x, xMinRef, xMaxRef);
182 updateRange(trace.y, yMinRef, yMaxRef);
183 updateRange(trace.z, zMinRef, zMaxRef);
184 }
185 for (const trace of plot3Traces) {
186 updateRange(trace.x, xMinRef, xMaxRef);
187 updateRange(trace.y, yMinRef, yMaxRef);
188 updateRange(trace.z, zMinRef, zMaxRef);
189 }
190 for (const trace of bar3Traces) {
191 updateRange(trace.x, xMinRef, xMaxRef);
192 updateRange(trace.y, yMinRef, yMaxRef);
193 updateRange(trace.z, zMinRef, zMaxRef);
194 // Bars extend to zero on z-axis
195 if (0 < zMinRef.v) zMinRef.v = 0;
196 }
197 for (const trace of bar3hTraces) {
198 // bar3h: bars extend along x-axis, positions on y and z axes
199 updateRange(trace.y, yMinRef, yMaxRef);
200 updateRange(trace.z, zMinRef, zMaxRef);
201 updateRange(trace.x, xMinRef, xMaxRef);
202 // Bars extend to zero on x-axis
203 if (0 < xMinRef.v) xMinRef.v = 0;
204 }
205 for (const trace of quiver3Traces) {
206 // Include both the arrow tails and the arrow heads.
207 updateRange(trace.x, xMinRef, xMaxRef);
208 updateRange(trace.y, yMinRef, yMaxRef);
209 updateRange(trace.z, zMinRef, zMaxRef);
210 updateRange(
211 trace.x.map((v, i) => v + (trace.u[i] ?? 0)),
212 xMinRef,
213 xMaxRef
214 );
215 updateRange(
216 trace.y.map((v, i) => v + (trace.v[i] ?? 0)),
217 yMinRef,
218 yMaxRef
219 );
220 updateRange(
221 trace.z.map((v, i) => v + (trace.w[i] ?? 0)),
222 zMinRef,
223 zMaxRef
224 );
225 }
227 xMin = xMinRef.v;
228 xMax = xMaxRef.v;
229 yMin = yMinRef.v;
230 yMax = yMaxRef.v;
231 zMin = zMinRef.v;
232 zMax = zMaxRef.v;
234 if (!isFinite(xMin)) return;
235 if (xMax === xMin) {
236 xMin -= 1;
237 xMax += 1;
238 }
239 if (yMax === yMin) {
240 yMin -= 1;
241 yMax += 1;
242 }
243 if (zMax === zMin) {
244 zMin -= 1;
245 zMax += 1;
246 }
248 const xRange = xMax - xMin || 1;
249 const yRange = yMax - yMin || 1;
250 const zRange2 = zMax - zMin || 1;
251 const rangeMax = Math.max(xRange, yRange, zRange2);
252 const cxData = (xMin + xMax) / 2;
253 const cyData = (yMin + yMax) / 2;
254 const czData = (zMin + zMax) / 2;
256 // For bar3/bar3h: use per-axis scaling when z range dominates x/y range.
257 // This prevents bars from appearing as thin sticks in histogram2-style data.
258 const hasOnlyBars =
259 surfTraces.length === 0 &&
260 plot3Traces.length === 0 &&
261 (bar3Traces.length > 0 || bar3hTraces.length > 0);
262 const barRangeMax = hasOnlyBars ? Math.max(xRange, yRange) : rangeMax;
263 // normBar scales x/y to fill the view; normZ still uses rangeMax for z
264 const normBar = (v: number, center: number) => (v - center) / barRangeMax;
265 const normBarZ = (v: number, center: number) =>
266 (v - center) / (hasOnlyBars ? Math.max(barRangeMax, zRange2) : rangeMax);
268 // Normalize a data point to [-0.5, 0.5] range
269 const norm = (v: number, center: number) => (v - center) / rangeMax;
271 // Color range (caxis) for surf vertex colors: the explicit color data C
272 // when present (surf(x,y,z,C)), otherwise the height Z, taken globally
273 // across all surf traces. This is independent of the geometry's z extent
274 // — using the z extent washes out a surface whose C range is much smaller
275 // (e.g. a solution plotted on a curved surface), and would disagree with
276 // the colorbar (which already uses the C range).
277 let cMin = Infinity;
278 let cMax = -Infinity;
279 for (const trace of surfTraces) {
280 for (const v of trace.c ?? trace.z) {
281 if (isFinite(v)) {
282 if (v < cMin) cMin = v;
283 if (v > cMax) cMax = v;
284 }
285 }
286 }
287 if (!isFinite(cMin)) {
288 cMin = zMin;
289 cMax = zMax;
290 }
291 const cRange = cMax - cMin || 1;
293 // ── Render surf traces ──────────────────────────────────────────────
294 for (const trace of surfTraces) {
295 const { rows, cols, x, y, z } = trace;
296 const alpha = trace.faceAlpha ?? 1;
298 // Build indexed geometry
299 const positions = new Float32Array(rows * cols * 3);
300 const colors = new Float32Array(rows * cols * 3);
302 for (let j = 0; j < cols; j++) {
303 for (let i = 0; i < rows; i++) {
304 const idx = j * rows + i; // column-major
305 const vi = i * cols + j; // vertex index for buffer (row-major)
307 const nx = norm(x[idx], cxData);
308 const ny = norm(y[idx], cyData);
309 const nz = norm(z[idx], czData);
311 // three.js: X=right, Y=up, Z=towards camera
312 // Map data X→three X, data Y→three Z, data Z→three Y
313 positions[vi * 3] = nx;
314 positions[vi * 3 + 1] = nz;
315 positions[vi * 3 + 2] = ny;
317 const cval = trace.c ? trace.c[idx] : z[idx];
318 const t = (cval - cMin) / cRange;
319 const [r, g, b] = colormapLookup(t);
320 colors[vi * 3] = r;
321 colors[vi * 3 + 1] = g;
322 colors[vi * 3 + 2] = b;
323 }
324 }
326 // Triangle indices
327 const indices: number[] = [];
328 for (let i = 0; i < rows - 1; i++) {
329 for (let j = 0; j < cols - 1; j++) {
330 const a = i * cols + j;
331 const b = i * cols + (j + 1);
332 const c = (i + 1) * cols + j;
333 const d = (i + 1) * cols + (j + 1);
334 indices.push(a, c, b);
335 indices.push(b, c, d);
336 }
337 }
339 const geometry = new THREE.BufferGeometry();
340 geometry.setAttribute(
341 "position",
342 new THREE.BufferAttribute(positions, 3)
343 );
344 geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
345 geometry.setIndex(indices);
346 geometry.computeVertexNormals();
348 // Determine effective shading mode
349 const shadingMode = shading ?? "faceted";
350 const useFlat = shadingMode === "faceted" || shadingMode === "flat";
352 // Face material
353 const showFaces = trace.faceColor !== "none";
354 if (showFaces) {
355 let faceMaterial: THREE.Material;
356 if (Array.isArray(trace.faceColor)) {
357 const [r, g, b] = trace.faceColor;
358 faceMaterial = new THREE.MeshPhongMaterial({
359 color: new THREE.Color(r, g, b),
360 flatShading: useFlat,
361 opacity: alpha,
362 transparent: alpha < 1,
363 side: THREE.DoubleSide,
364 });
365 } else {
366 faceMaterial = new THREE.MeshPhongMaterial({
367 vertexColors: true,
368 flatShading: useFlat,
369 opacity: alpha,
370 transparent: alpha < 1,
371 side: THREE.DoubleSide,
372 });
373 }
374 scene.add(new THREE.Mesh(geometry, faceMaterial));
375 }
377 // Edge wireframe — hidden for "flat" and "interp" shading modes
378 const showEdges = trace.edgeColor !== "none" && shadingMode === "faceted";
379 if (showEdges) {
380 const edgePositions: number[] = [];
381 const edgeColors: number[] = [];
382 for (let i = 0; i < rows; i++) {
383 for (let j = 0; j < cols; j++) {
384 const vi = i * cols + j;
385 // Horizontal edge (to the right)
386 if (j < cols - 1) {
387 const vi2 = i * cols + (j + 1);
388 edgePositions.push(
389 positions[vi * 3],
390 positions[vi * 3 + 1],
391 positions[vi * 3 + 2],
392 positions[vi2 * 3],
393 positions[vi2 * 3 + 1],
394 positions[vi2 * 3 + 2]
395 );
396 edgeColors.push(
397 colors[vi * 3],
398 colors[vi * 3 + 1],
399 colors[vi * 3 + 2],
400 colors[vi2 * 3],
401 colors[vi2 * 3 + 1],
402 colors[vi2 * 3 + 2]
403 );
404 }
405 // Vertical edge (downward)
406 if (i < rows - 1) {
407 const vi2 = (i + 1) * cols + j;
408 edgePositions.push(
409 positions[vi * 3],
410 positions[vi * 3 + 1],
411 positions[vi * 3 + 2],
412 positions[vi2 * 3],
413 positions[vi2 * 3 + 1],
414 positions[vi2 * 3 + 2]
415 );
416 edgeColors.push(
417 colors[vi * 3],
418 colors[vi * 3 + 1],
419 colors[vi * 3 + 2],
420 colors[vi2 * 3],
421 colors[vi2 * 3 + 1],
422 colors[vi2 * 3 + 2]
423 );
424 }
425 }
426 }
428 const edgeGeometry = new THREE.BufferGeometry();
429 edgeGeometry.setAttribute(
430 "position",
431 new THREE.Float32BufferAttribute(edgePositions, 3)
432 );
434 let edgeMat: THREE.LineBasicMaterial;
435 if (Array.isArray(trace.edgeColor)) {
436 const [r, g, b] = trace.edgeColor;
437 edgeMat = new THREE.LineBasicMaterial({
438 color: new THREE.Color(r, g, b),
439 });
440 } else {
441 edgeMat = new THREE.LineBasicMaterial({
442 color: 0x000000,
443 opacity: 0.3,
444 transparent: true,
445 });
446 }
447 scene.add(new THREE.LineSegments(edgeGeometry, edgeMat));
448 }
449 }
451 // ── Render plot3 traces ─────────────────────────────────────────────
452 for (let ti = 0; ti < plot3Traces.length; ti++) {
453 const trace = plot3Traces[ti];
454 const { x, y, z } = trace;
456 // Determine color
457 const defaultColor = TRACE_COLORS[ti % TRACE_COLORS.length];
458 const color = trace.color ?? defaultColor;
459 const threeColor = new THREE.Color(color[0], color[1], color[2]);
461 // Build line points (skip NaN/Inf to create line breaks)
462 const showLine = trace.lineStyle !== "none";
463 if (showLine) {
464 // Build segments of consecutive finite points
465 const segments: THREE.Vector3[][] = [];
466 let currentSegment: THREE.Vector3[] = [];
468 for (let i = 0; i < x.length; i++) {
469 if (isFinite(x[i]) && isFinite(y[i]) && isFinite(z[i])) {
470 const nx = norm(x[i], cxData);
471 const ny = norm(y[i], cyData);
472 const nz = norm(z[i], czData);
473 // Map: data X→three X, data Z→three Y, data Y→three Z
474 currentSegment.push(new THREE.Vector3(nx, nz, ny));
475 } else {
476 if (currentSegment.length > 0) {
477 segments.push(currentSegment);
478 currentSegment = [];
479 }
480 }
481 }
482 if (currentSegment.length > 0) {
483 segments.push(currentSegment);
484 }
486 // Use Line2 + LineMaterial for proper line width support
487 // (THREE.LineBasicMaterial.linewidth is ignored on most platforms)
488 const lw = trace.lineWidth ?? 2;
489 const isDashed =
490 trace.lineStyle === "--" ||
491 trace.lineStyle === ":" ||
492 trace.lineStyle === "-.";
494 for (const seg of segments) {
495 if (seg.length < 2) continue;
496 const positions: number[] = [];
497 for (const pt of seg) {
498 positions.push(pt.x, pt.y, pt.z);
499 }
501 if (isDashed) {
502 // Fall back to LineDashedMaterial for dash patterns
503 // (Line2/LineMaterial doesn't support dashes)
504 const dashedMat = new THREE.LineDashedMaterial({
505 color: threeColor,
506 linewidth: lw,
507 dashSize: trace.lineStyle === ":" ? 0.01 : 0.03,
508 gapSize: trace.lineStyle === ":" ? 0.02 : 0.015,
509 });
510 const geo = new THREE.BufferGeometry().setFromPoints(seg);
511 const line = new THREE.Line(geo, dashedMat);
512 line.computeLineDistances();
513 scene.add(line);
514 } else {
515 const geo = new LineGeometry();
516 geo.setPositions(positions);
517 const mat = new LineMaterial({
518 color: threeColor.getHex(),
519 linewidth: lw,
520 worldUnits: false,
521 resolution: new THREE.Vector2(
522 st.renderer.domElement.width || 800,
523 st.renderer.domElement.height || 600
524 ),
525 });
526 scene.add(new Line2(geo, mat));
527 }
528 }
529 }
531 // Draw markers as small spheres/points
532 if (trace.marker && trace.marker !== "none") {
533 const markerSize = (trace.markerSize ?? 6) / 600; // scale to normalized space
534 const markerColor = trace.markerEdgeColor
535 ? new THREE.Color(
536 trace.markerEdgeColor[0],
537 trace.markerEdgeColor[1],
538 trace.markerEdgeColor[2]
539 )
540 : threeColor;
542 const indices = trace.markerIndices
543 ? trace.markerIndices.map(i => i - 1) // 1-based
544 : Array.from({ length: x.length }, (_, i) => i);
546 const markerGeo = new THREE.SphereGeometry(markerSize, 8, 8);
547 const markerMat = new THREE.MeshBasicMaterial({ color: markerColor });
549 for (const i of indices) {
550 if (i < 0 || i >= x.length) continue;
551 if (!isFinite(x[i]) || !isFinite(y[i]) || !isFinite(z[i])) continue;
552 const nx = norm(x[i], cxData);
553 const ny = norm(y[i], cyData);
554 const nz = norm(z[i], czData);
555 const mesh = new THREE.Mesh(markerGeo, markerMat);
556 mesh.position.set(nx, nz, ny);
557 scene.add(mesh);
558 }
559 }
560 }
562 // ── Render quiver3 traces (3-D arrows) ───────────────────────────────
563 for (const trace of quiver3Traces) {
564 const { x, y, z, u, v, w } = trace;
565 const color = trace.color ?? [0, 0.447, 0.741];
566 const threeColor = new THREE.Color(color[0], color[1], color[2]);
567 const lw = trace.lineWidth ?? 0.5;
568 // data (x,y,z) → three (X, Z, Y), matching the surf/plot3 mapping.
569 const toThree = (dx: number, dy: number, dz: number) =>
570 new THREE.Vector3(norm(dx, cxData), norm(dz, czData), norm(dy, cyData));
571 const up = new THREE.Vector3(0, 1, 0);
572 const segs: number[] = []; // pairs of endpoints for LineSegments
574 for (let i = 0; i < x.length; i++) {
575 if (
576 !isFinite(x[i]) ||
577 !isFinite(y[i]) ||
578 !isFinite(z[i]) ||
579 !isFinite(u[i]) ||
580 !isFinite(v[i]) ||
581 !isFinite(w[i])
582 )
583 continue;
584 const tail = toThree(x[i], y[i], z[i]);
585 const head = toThree(x[i] + u[i], y[i] + v[i], z[i] + w[i]);
586 // Shaft
587 segs.push(tail.x, tail.y, tail.z, head.x, head.y, head.z);
589 if (trace.showArrowHead) {
590 const dir = new THREE.Vector3().subVectors(head, tail);
591 const len = dir.length();
592 if (len > 1e-9) {
593 dir.multiplyScalar(1 / len);
594 let perp = new THREE.Vector3().crossVectors(dir, up);
595 if (perp.lengthSq() < 1e-12)
596 perp = new THREE.Vector3().crossVectors(
597 dir,
598 new THREE.Vector3(1, 0, 0)
599 );
600 perp.normalize();
601 const barb = Math.min(0.3 * len, len);
602 const back = dir.clone().multiplyScalar(-1);
603 const cosA = Math.cos((20 * Math.PI) / 180);
604 const sinA = Math.sin((20 * Math.PI) / 180);
605 const b1 = head
606 .clone()
607 .addScaledVector(back, barb * cosA)
608 .addScaledVector(perp, barb * sinA);
609 const b2 = head
610 .clone()
611 .addScaledVector(back, barb * cosA)
612 .addScaledVector(perp, -barb * sinA);
613 segs.push(head.x, head.y, head.z, b1.x, b1.y, b1.z);
614 segs.push(head.x, head.y, head.z, b2.x, b2.y, b2.z);
615 }
616 }
617 }
619 if (segs.length > 0) {
620 const geo = new THREE.BufferGeometry();
621 geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3));
622 const mat = new THREE.LineBasicMaterial({
623 color: threeColor,
624 linewidth: lw,
625 });
626 scene.add(new THREE.LineSegments(geo, mat));
627 }
629 // Markers at the arrow bases (LineSpec marker or 'filled').
630 if (trace.marker && trace.marker !== "none") {
631 const markerSize = 6 / 600;
632 const markerGeo = new THREE.SphereGeometry(markerSize, 8, 8);
633 const markerMat = new THREE.MeshBasicMaterial({ color: threeColor });
634 for (let i = 0; i < x.length; i++) {
635 if (!isFinite(x[i]) || !isFinite(y[i]) || !isFinite(z[i])) continue;
636 const p = toThree(x[i], y[i], z[i]);
637 const mesh = new THREE.Mesh(markerGeo, markerMat);
638 mesh.position.set(p.x, p.y, p.z);
639 scene.add(mesh);
640 }
641 }
642 }
644 // ── Render bar3 traces (vertical 3D bars) ────────────────────────────
645 for (const trace of bar3Traces) {
646 const halfW = (trace.width / 2) * 0.9; // slight shrink to show gaps
647 const zRangeT = zMax - zMin || 1;
648 for (let i = 0; i < trace.x.length; i++) {
649 const bx = trace.x[i];
650 const by = trace.y[i];
651 const bz = trace.z[i];
652 if (!isFinite(bz)) continue;
654 const barHeight = Math.abs(normBarZ(bz, czData) - normBarZ(0, czData));
655 const barCenter = (normBarZ(bz, czData) + normBarZ(0, czData)) / 2;
657 const geo = new THREE.BoxGeometry(
658 (halfW * 2) / barRangeMax,
659 barHeight,
660 (halfW * 2) / barRangeMax
661 );
663 const t = (bz - zMin) / zRangeT;
664 const [cr, cg, cb] = trace.color ?? colormapLookup(t);
665 const mat = new THREE.MeshPhongMaterial({
666 color: new THREE.Color(cr, cg, cb),
667 });
668 const mesh = new THREE.Mesh(geo, mat);
669 // data X→three X, data Z→three Y, data Y→three Z
670 mesh.position.set(normBar(bx, cxData), barCenter, normBar(by, cyData));
671 scene.add(mesh);
673 // Edge wireframe
674 const edges = new THREE.EdgesGeometry(geo);
675 const lineMat = new THREE.LineBasicMaterial({
676 color: 0x000000,
677 opacity: 0.3,
678 transparent: true,
679 });
680 const wireframe = new THREE.LineSegments(edges, lineMat);
681 wireframe.position.copy(mesh.position);
682 scene.add(wireframe);
683 }
684 }
686 // ── Render bar3h traces (horizontal 3D bars) ───────────────────────
687 for (const trace of bar3hTraces) {
688 const halfW = (trace.width / 2) * 0.9;
689 const xRangeH = xMax - xMin || 1;
690 // bar3h: x=positions (category axis, mapped to z-axis in MATLAB),
691 // y=bar lengths (value axis, mapped to y/horizontal),
692 // z values are the bar lengths, x values are positions
693 // Reinterpret: y-positions on z-axis, x-values are bar lengths on x-axis
694 for (let i = 0; i < trace.x.length; i++) {
695 const pos = trace.y[i]; // position on y-axis
696 const colIdx = trace.x[i]; // position on x-axis (column)
697 const len = trace.z[i]; // bar length along x-axis
698 if (!isFinite(len)) continue;
700 const barLength = Math.abs(normBar(len, cxData) - normBar(0, cxData));
701 const barCenter = (normBar(len, cxData) + normBar(0, cxData)) / 2;
703 const geo = new THREE.BoxGeometry(
704 barLength,
705 (halfW * 2) / barRangeMax,
706 (halfW * 2) / barRangeMax
707 );
709 const t = (len - xMin) / xRangeH;
710 const [cr, cg, cb] = trace.color ?? colormapLookup(t);
711 const mat = new THREE.MeshPhongMaterial({
712 color: new THREE.Color(cr, cg, cb),
713 });
714 const mesh = new THREE.Mesh(geo, mat);
715 mesh.position.set(
716 barCenter,
717 normBar(colIdx, czData),
718 normBar(pos, cyData)
719 );
720 scene.add(mesh);
722 const edges = new THREE.EdgesGeometry(geo);
723 const lineMat = new THREE.LineBasicMaterial({
724 color: 0x000000,
725 opacity: 0.3,
726 transparent: true,
727 });
728 const wireframe = new THREE.LineSegments(edges, lineMat);
729 wireframe.position.copy(mesh.position);
730 scene.add(wireframe);
731 }
732 }
734 // Axis lines (hidden by `axis off`)
735 if (axisVisible !== false) {
736 addAxisLines(
737 scene,
738 xMin,
739 xMax,
740 yMin,
741 yMax,
742 zMin,
743 zMax,
744 rangeMax,
745 cxData,
746 cyData,
747 czData
748 );
749 }
750 }, [
751 surfTraces,
752 plot3Traces,
753 bar3Traces,
754 bar3hTraces,
755 quiver3Traces,
756 shading,
757 axisVisible,
758 ]);
760 // Compute color range for the colorbar from surf traces (uses C if present,
761 // otherwise Z). Falls back to bar3 z values when no surf traces are present.
762 let cbMin = Infinity;
763 let cbMax = -Infinity;
764 for (const t of surfTraces) {
765 const arr = t.c ?? t.z;
766 for (const v of arr) {
767 if (isFinite(v)) {
768 if (v < cbMin) cbMin = v;
769 if (v > cbMax) cbMax = v;
770 }
771 }
772 }
773 if (!isFinite(cbMin)) {
774 for (const t of bar3Traces) {
775 for (const v of t.z) {
776 if (isFinite(v)) {
777 if (v < cbMin) cbMin = v;
778 if (v > cbMax) cbMax = v;
779 }
780 }
781 }
782 }
783 const haveColorRange = isFinite(cbMin) && isFinite(cbMax);
784 if (cbMin === cbMax) {
785 cbMin -= 0.5;
786 cbMax += 0.5;
787 }
789 return (
790 <div style={{ position: "relative", width: "100%", height: "100%" }}>
791 <div ref={containerRef} style={{ position: "absolute", inset: 0 }} />
792 {colorbar && haveColorRange && (
793 <ColorbarOverlay
794 location={(colorbarLocation ?? "eastoutside").toLowerCase()}
795 dMin={cbMin}
796 dMax={cbMax}
797 colormap={colormap}
798 />
799 )}
800 </div>
801 );
804// ── Colorbar overlay (HTML, drawn on top of the Three.js canvas) ────────
806function ColorbarOverlay({
807 location,
808 dMin,
809 dMax,
810 colormap,
811}: {
812 location: string;
813 dMin: number;
814 dMax: number;
815 colormap?: string;
816}) {
817 // Build a CSS gradient from N samples of the colormap.
818 // (colormap name is currently unused — surfColormap.colormapLookup uses parula.)
819 void colormap;
820 const N = 32;
821 const stops: string[] = [];
822 for (let i = 0; i < N; i++) {
823 const t = i / (N - 1);
824 const [r, g, b] = colormapLookup(t);
825 const rgb = `rgb(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)})`;
826 stops.push(`${rgb} ${(t * 100).toFixed(2)}%`);
827 }
828 const horizontal =
829 location === "northoutside" ||
830 location === "southoutside" ||
831 location === "north" ||
832 location === "south";
833 // Vertical gradients go bottom→top so the max sits at the top.
834 const gradient = horizontal
835 ? `linear-gradient(to right, ${stops.join(",")})`
836 : `linear-gradient(to top, ${stops.join(",")})`;
838 const fmt = (v: number) =>
839 Number.isInteger(v) ? String(v) : v.toPrecision(3);
841 // Position styles per location
842 const barThickness = 16;
843 const containerStyle: CSSProperties = {
844 position: "absolute",
845 pointerEvents: "none",
846 fontFamily: "sans-serif",
847 fontSize: 10,
848 color: "#333",
849 };
851 const barStyle: CSSProperties = {
852 background: gradient,
853 border: "1px solid #999",
854 boxSizing: "border-box",
855 };
857 switch (location) {
858 case "eastoutside":
859 return (
860 <div
861 style={{
862 ...containerStyle,
863 top: 12,
864 bottom: 12,
865 right: 8,
866 width: 50,
867 display: "flex",
868 alignItems: "stretch",
869 }}
870 >
871 <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
872 <div
873 style={{
874 marginLeft: 4,
875 display: "flex",
876 flexDirection: "column",
877 justifyContent: "space-between",
878 }}
879 >
880 <span>{fmt(dMax)}</span>
881 <span>{fmt(dMin)}</span>
882 </div>
883 </div>
884 );
885 case "westoutside":
886 return (
887 <div
888 style={{
889 ...containerStyle,
890 top: 12,
891 bottom: 12,
892 left: 8,
893 width: 50,
894 display: "flex",
895 alignItems: "stretch",
896 flexDirection: "row-reverse",
897 }}
898 >
899 <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
900 <div
901 style={{
902 marginRight: 4,
903 display: "flex",
904 flexDirection: "column",
905 justifyContent: "space-between",
906 textAlign: "right",
907 }}
908 >
909 <span>{fmt(dMax)}</span>
910 <span>{fmt(dMin)}</span>
911 </div>
912 </div>
913 );
914 case "northoutside":
915 return (
916 <div
917 style={{
918 ...containerStyle,
919 left: 12,
920 right: 12,
921 top: 8,
922 height: 32,
923 display: "flex",
924 flexDirection: "column",
925 }}
926 >
927 <div
928 style={{
929 display: "flex",
930 justifyContent: "space-between",
931 marginBottom: 2,
932 }}
933 >
934 <span>{fmt(dMin)}</span>
935 <span>{fmt(dMax)}</span>
936 </div>
937 <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
938 </div>
939 );
940 case "southoutside":
941 return (
942 <div
943 style={{
944 ...containerStyle,
945 left: 12,
946 right: 12,
947 bottom: 8,
948 height: 32,
949 display: "flex",
950 flexDirection: "column-reverse",
951 }}
952 >
953 <div
954 style={{
955 display: "flex",
956 justifyContent: "space-between",
957 marginTop: 2,
958 }}
959 >
960 <span>{fmt(dMin)}</span>
961 <span>{fmt(dMax)}</span>
962 </div>
963 <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
964 </div>
965 );
966 case "east":
967 return (
968 <div
969 style={{
970 ...containerStyle,
971 top: 24,
972 bottom: 24,
973 right: 24,
974 width: 50,
975 display: "flex",
976 flexDirection: "row-reverse",
977 alignItems: "stretch",
978 }}
979 >
980 <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
981 <div
982 style={{
983 marginRight: 4,
984 display: "flex",
985 flexDirection: "column",
986 justifyContent: "space-between",
987 textAlign: "right",
988 }}
989 >
990 <span>{fmt(dMax)}</span>
991 <span>{fmt(dMin)}</span>
992 </div>
993 </div>
994 );
995 case "west":
996 return (
997 <div
998 style={{
999 ...containerStyle,
1000 top: 24,
1001 bottom: 24,
1002 left: 24,
1003 width: 50,
1004 display: "flex",
1005 alignItems: "stretch",
1006 }}
1008 <div style={{ ...barStyle, width: barThickness, height: "100%" }} />
1009 <div
1010 style={{
1011 marginLeft: 4,
1012 display: "flex",
1013 flexDirection: "column",
1014 justifyContent: "space-between",
1015 }}
1017 <span>{fmt(dMax)}</span>
1018 <span>{fmt(dMin)}</span>
1019 </div>
1020 </div>
1021 );
1022 case "north":
1023 return (
1024 <div
1025 style={{
1026 ...containerStyle,
1027 left: 24,
1028 right: 24,
1029 top: 24,
1030 height: 32,
1031 display: "flex",
1032 flexDirection: "column-reverse",
1033 }}
1035 <div
1036 style={{
1037 display: "flex",
1038 justifyContent: "space-between",
1039 marginTop: 2,
1040 }}
1042 <span>{fmt(dMin)}</span>
1043 <span>{fmt(dMax)}</span>
1044 </div>
1045 <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
1046 </div>
1047 );
1048 case "south":
1049 return (
1050 <div
1051 style={{
1052 ...containerStyle,
1053 left: 24,
1054 right: 24,
1055 bottom: 24,
1056 height: 32,
1057 display: "flex",
1058 flexDirection: "column",
1059 }}
1061 <div
1062 style={{
1063 display: "flex",
1064 justifyContent: "space-between",
1065 marginBottom: 2,
1066 }}
1068 <span>{fmt(dMin)}</span>
1069 <span>{fmt(dMax)}</span>
1070 </div>
1071 <div style={{ ...barStyle, height: barThickness, width: "100%" }} />
1072 </div>
1073 );
1074 default:
1075 return null;
1079function addAxisLines(
1080 scene: THREE.Scene,
1081 xMin: number,
1082 xMax: number,
1083 yMin: number,
1084 yMax: number,
1085 zMin: number,
1086 zMax: number,
1087 rangeMax: number,
1088 cxData: number,
1089 cyData: number,
1090 czData: number
1091) {
1092 const norm = (v: number, center: number) => (v - center) / rangeMax;
1094 const axes: {
1095 from: [number, number, number];
1096 to: [number, number, number];
1097 }[] = [
1098 { from: [xMin, yMin, zMin], to: [xMax, yMin, zMin] },
1099 { from: [xMin, yMin, zMin], to: [xMin, yMax, zMin] },
1100 { from: [xMin, yMin, zMin], to: [xMin, yMin, zMax] },
1101 ];
1103 const mat = new THREE.LineBasicMaterial({ color: 0x333333 });
1105 for (const axis of axes) {
1106 const pts = [axis.from, axis.to].map(([ax, ay, az]) => {
1107 const nx = norm(ax, cxData);
1108 const ny = norm(ay, cyData);
1109 const nz = norm(az, czData);
1110 return new THREE.Vector3(nx, nz, ny); // data X→X, data Z→Y, data Y→Z
1111 });
1112 const geo = new THREE.BufferGeometry().setFromPoints(pts);
1113 scene.add(new THREE.Line(geo, mat));
moveopenescclose