Something went wrong. Try again.
svelte astro
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067<script lang="ts"> // Architecture of this component, top to bottom: // // 1. UI state -> which columns are shown, where the slice is // 2. Derived state -> columns -> scales -> normalized points -> slice // (this whole chain lives in scatter3d-data.ts, // here it's just wired together with $derived) // 3. OGL scene -> imperative layer; its effects ONLY read the // derived state and update 3D objects. // No data math happens here. // // The real-unit labels (sliceRealPos/sliceRealWidth) use the same Scale // that normalizes the points, so they can never drift out of sync with // what's actually being plotted. // // MIGRATION NOTE (three.js -> OGL): OGL's scene graph is much thinner // than three.js's -- there's no Points/Sprite/GridHelper/BoxGeometry // out of the box, and no built-in disposal. Below, every one of those // is rebuilt as a plain Mesh + a small hand-written GLSL Program. The // data layer (three-plot.ts) never imported three.js in the first // place, so none of it changes.
import { getColor } from "../color"; import { onMount } from "svelte"; import { Renderer, Camera, Transform, Mesh, Program, Geometry, Texture, Color, Vec3, Orbit, Box, Cylinder, } from "ogl"; import SlicePlot from "./SlicePlot.svelte"; import { PAINTS, proyectState, ThreePlotState, headers, filteredRows, } from "../State.svelte"; import { getColumns, createScale, normalizePoints, sliceAt, type Points3D, } from "../three-plot";
$effect(() => { if (!ThreePlotState.xColumn) { const xColumn = proyectState.current?.data.col([ "ra", "ra_icrs", "RA", "RA_ICRS", ]); if (xColumn === undefined) return; ThreePlotState.xColumn = xColumn; } if (!ThreePlotState.yColumn) { const yColumn = proyectState.current?.data.col([ "dec", "de_icrs", "DEC", "DE_ICRS", ]); if (yColumn === undefined) return; ThreePlotState.yColumn = yColumn; } if (!ThreePlotState.zColumn) { const zColumn = proyectState.current?.data.col(["Plx", "plx"]); if (zColumn === undefined) return; ThreePlotState.zColumn = zColumn; } });
const xMeta = $derived.by(() => { if (ThreePlotState.xColumn === null) return null; const xHeader = headers()![ThreePlotState.xColumn]; const meta = proyectState.current!.data.meta([xHeader]); if (!meta) return null; return meta; }); const yMeta = $derived.by(() => { if (ThreePlotState.yColumn === null) return null; const xHeader = headers()![ThreePlotState.yColumn]; const meta = proyectState.current!.data.meta([xHeader]); if (!meta) return null; return meta; }); const zMeta = $derived.by(() => { if (ThreePlotState.zColumn === null) return null; const xHeader = headers()![ThreePlotState.zColumn]; const meta = proyectState.current!.data.meta([xHeader]); if (!meta) return null; return meta; });
// The name-to-index lookup for the current project's columns, in file // order — used to resolve a stored column index back to a name (e.g. // for meta()/unit lookups) and to populate the X/Y/Z <select>s below. let columnNames = $derived(headers() ?? []);
// Rows that pass every active filter (see State.svelte.ts), resolved to // their actual string[] contents. This is what makes the plot reactive // to the filter toggles — reading filteredRows() here means this // recomputes whenever a filter's `active` flag (or the project) changes. let visibleRows = $derived.by(() => { const data = proyectState.current?.data; if (!data) return []; return filteredRows() .map((i) => data.row(i)) .filter((row): row is string[] => row !== null); });
// x/y/z/bv have to be filtered TOGETHER: if a row is dropped because // (say) its parallax cell is empty, it needs to be dropped from every // array at once — otherwise index i stops referring to the same star // across xValues/yValues/zValues/bvValues and points3D. getColumns // does exactly that (see three-plot.ts for the full explanation); // that's also why colorColumn is computed above this, so it can be // included in the same filter pass. let colorColumn = $derived.by(() => { const activeProject = proyectState.current; if ( !activeProject || activeProject.color < 0 || !PAINTS[activeProject.color] ) { return null; // O el índice por defecto de bp-rp } return activeProject.data.col(PAINTS[activeProject.color].columnNames); });
let [xValues, yValues, zValues, bvValues] = $derived( getColumns(visibleRows, [ ThreePlotState.xColumn, ThreePlotState.yColumn, ThreePlotState.zColumn, colorColumn, ]), );
let xScale = $derived(createScale(xValues)); let yScale = $derived(createScale(yValues)); let zScale = $derived(createScale(zValues));
let points3D = $derived( normalizePoints(xValues, yValues, zValues, xScale, yScale, zScale), );
let sliceData = $derived( sliceAt(points3D, ThreePlotState.slicePos, ThreePlotState.sliceWidth), );
// Real units for the Z column, derived from the same zScale that // normalizes the points — a single place that could ever be "wrong". let sliceRealPos = $derived(zScale.toReal(ThreePlotState.slicePos)); let sliceRealWidth = $derived( (ThreePlotState.sliceWidth * zScale.range) / 2, );
let container!: HTMLDivElement;
// OGL core objects. `gl` is the raw WebGL context OGL wraps everything // else around — typed `any` here since the exact exported context type // name has moved around between OGL versions; swap in your installed // version's `OGLRenderingContext` type if you want it precise. let gl: any; let renderer: Renderer; let camera: Camera; let controls: Orbit; let scene: Transform; let pointCloud: Mesh; let pointsProgram: Program | undefined; let axesHelper: Transform; let axisLabels: Transform; let gridHelper: Mesh; let sliceSlab: Mesh; let resizeObserver: ResizeObserver;
// OGL has no Object3D-style dispose(): a Geometry owns raw // WebGLBuffers, a Program owns a WebGLProgram, and a Texture owns a // WebGLTexture, none of which get garbage collected on their own. This // walks the tree and deletes each of those by hand — the OGL analogue // of disposeObject3D from the three.js version. (Property names like // `.buffer`/`.program`/`.texture` are OGL's internal handles to the // raw WebGL objects; check them against your installed ogl version if // a rebuild ever throws here.) function disposeNode(node: Transform | Mesh | undefined | null) { if (!node) return; [...node.children].forEach((child) => disposeNode(child as Transform));
const mesh = node as Mesh; if (mesh.geometry) { Object.values(mesh.geometry.attributes ?? {}).forEach( (attr: any) => { if (attr?.buffer) gl.deleteBuffer(attr.buffer); }, ); } if (mesh.program) { Object.values(mesh.program.uniforms ?? {}).forEach((u: any) => { if (u?.value?.texture) gl.deleteTexture(u.value.texture); }); if (mesh.program.program) gl.deleteProgram(mesh.program.program); } node.setParent(null); }
// ---- Shared GLSL programs ------------------------------------------- // // three.js's MeshBasicMaterial/PointsMaterial/SpriteMaterial all become // hand-rolled Program pairs here, since OGL only gives you Geometry + // Program + Mesh and expects you to write the shader.
// Flat, unlit single color — the three.js MeshBasicMaterial equivalent // used for the axis lines/arrowheads. const solidColorVertex = /* glsl */ ` attribute vec3 position; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; const solidColorFragment = /* glsl */ ` precision highp float; uniform vec3 uColor; void main() { gl_FragColor = vec4(uColor, 1.0); } `; // Same, but with a uniform alpha — used for the translucent slice slab. const solidColorAlphaFragment = /* glsl */ ` precision highp float; uniform vec3 uColor; uniform float uAlpha; void main() { gl_FragColor = vec4(uColor, uAlpha); } `;
// Per-vertex color, no lighting — used for the grid lines. const vertexColorVertex = /* glsl */ ` attribute vec3 position; attribute vec3 color; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; varying vec3 vColor; void main() { vColor = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; const vertexColorFragment = /* glsl */ ` precision highp float; varying vec3 vColor; void main() { gl_FragColor = vec4(vColor, 1.0); } `;
// Point cloud: per-vertex color plus perspective size attenuation. // `uHeight`/`uFov` reproduce three.js PointsMaterial's own // sizeAttenuation formula (scale = viewportHeight / (2*tan(fov/2))), // so points shrink with distance exactly like the original did. const pointsVertex = /* glsl */ ` attribute vec3 position; attribute vec3 color; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform float uSize; uniform float uHeight; uniform float uFov; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mvPosition; float scale = uHeight / (2.0 * tan(uFov * 0.5)); gl_PointSize = uSize * scale / -mvPosition.z; } `; const pointsFragment = /* glsl */ ` precision highp float; varying vec3 vColor; void main() { gl_FragColor = vec4(vColor, 1.0); } `;
// Billboarded, textured quad — the Sprite/SpriteMaterial replacement // used for the axis limit labels. Rather than counter-rotating the // mesh's own transform every frame, the vertex shader throws away // whatever rotation the object has and rebuilds the quad's corners // from the camera's right/up vectors directly, so it always faces the // camera no matter how OrbitControls has rotated the scene. const billboardVertex = /* glsl */ ` attribute vec3 position; attribute vec2 uv; uniform mat4 modelMatrix; uniform mat4 viewMatrix; uniform mat4 projectionMatrix; varying vec2 vUv; void main() { vUv = uv; vec3 worldPos = (modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; vec3 cameraRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]); vec3 cameraUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]); vec3 billboardPos = worldPos + cameraRight * position.x + cameraUp * position.y; gl_Position = projectionMatrix * viewMatrix * vec4(billboardPos, 1.0); } `; const billboardFragment = /* glsl */ ` precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { vec4 tex = texture2D(tMap, vUv); if (tex.a < 0.01) discard; gl_FragColor = tex; } `;
// Arrowhead length used by both buildAxes (to draw it) and // buildAxisLabels (to clear it when placing the positive-end labels). const axisHeadLen = 0.12;
// Axes and grid are purely decorative and fixed to the [-1, 1] cube: they // never depend on the data, so they're built once in onMount instead of // being rebuilt every time a column changes. function buildAxes(): Transform { const axes = new Transform(); const r = 0.006; // line thickness const len = 2; const headR = 0.035; // arrowhead (cone) radius
const axisColors: [number, number, number][] = [ [1, 0.2, 0.2], // X red (#ff3333) [0.2, 0.8, 0.2], // Y green (#33cc33) [0.2, 0.6, 1], // Z blue (#3399ff) ];
// Builds one axis (line + arrowhead) authored along local +Y, so the // same rotation used to point the old plain cylinder at X/Y/Z also // points the arrowhead at that axis's positive end. A cone is just // a Cylinder with radiusTop = 0. function buildAxisLine(color: [number, number, number]): Transform { const group = new Transform(); const program = new Program(gl, { vertex: solidColorVertex, fragment: solidColorFragment, uniforms: { uColor: { value: new Color(...color) } }, });
const line = new Mesh(gl, { geometry: new Cylinder(gl, { radiusTop: r, radiusBottom: r, height: len, radialSegments: 8, }), program, }); line.setParent(group);
const head = new Mesh(gl, { geometry: new Cylinder(gl, { radiusTop: 0, radiusBottom: headR, height: axisHeadLen, radialSegments: 8, }), program, }); head.position.y = len / 2 + axisHeadLen / 2; head.setParent(group);
return group; }
const xAxis = buildAxisLine(axisColors[0]); xAxis.rotation.z = -Math.PI / 2; xAxis.setParent(axes);
const yAxis = buildAxisLine(axisColors[1]); yAxis.setParent(axes);
const zAxis = buildAxisLine(axisColors[2]); zAxis.rotation.x = Math.PI / 2; zAxis.setParent(axes);
return axes; }
// OGL has no GridHelper, so this builds the same "20 divisions across a // 2-unit cube, darker center line" grid as a single gl.LINES mesh with // a per-vertex color attribute. function buildGrid(): Mesh { const divisions = 20; const size = 2; const half = size / 2; const step = size / divisions;
const centerColor: [number, number, number] = [0, 0, 0]; // #000000 const gridColor: [number, number, number] = [ 0xd8 / 255, 0xd8 / 255, 0xd0 / 255, ]; // #d8d8d0
const positions: number[] = []; const colors: number[] = [];
for (let i = 0; i <= divisions; i++) { const k = -half + i * step; const color = i === divisions / 2 ? centerColor : gridColor;
positions.push(-half, 0, k, half, 0, k); colors.push(...color, ...color);
positions.push(k, 0, -half, k, 0, half); colors.push(...color, ...color); }
const geometry = new Geometry(gl, { position: { size: 3, data: new Float32Array(positions) }, color: { size: 3, data: new Float32Array(colors) }, });
const program = new Program(gl, { vertex: vertexColorVertex, fragment: vertexColorFragment, });
return new Mesh(gl, { geometry, program, mode: gl.LINES }); }
// Renders `text` onto a canvas and wraps it in a billboarded quad, // since OGL (like three.js) has no built-in text mesh. See // billboardVertex above for how it stays camera-facing. function makeTextSprite(text: string, color: string): Mesh { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d")!; const fontSize = 48; const font = `600 ${fontSize}px "JetBrains Mono", monospace`; const padding = 12;
const strokeWidth = 6;
ctx.font = font; canvas.width = Math.ceil(ctx.measureText(text).width) + padding * 2 + strokeWidth; canvas.height = fontSize + padding * 2;
// Resizing the canvas resets its 2D context state, so font/fill // have to be re-applied after setting width/height. ctx.font = font; ctx.textBaseline = "middle"; ctx.lineJoin = "round";
// Black outline first, then the fill color on top, so the label // stays legible over both the white background and the point cloud. ctx.lineWidth = strokeWidth; ctx.strokeStyle = "black"; ctx.strokeText(text, padding, canvas.height / 2);
ctx.fillStyle = color; ctx.fillText(text, padding, canvas.height / 2);
const texture = new Texture(gl, { image: canvas, generateMipmaps: false, minFilter: gl.LINEAR, magFilter: gl.LINEAR, });
// Fixed world-space height; width follows the canvas aspect ratio // so the text doesn't look stretched. The quad is built directly // in local XY around the origin (no separate "scale" step needed // like three.js's Sprite, since the billboard shader reads this // geometry straight from local space). const worldHeight = 0.09; const worldWidth = (worldHeight * canvas.width) / canvas.height; const hw = worldWidth / 2; const hh = worldHeight / 2;
const geometry = new Geometry(gl, { position: { size: 3, data: new Float32Array([ -hw, -hh, 0, hw, -hh, 0, hw, hh, 0, -hw, hh, 0, ]), }, uv: { size: 2, data: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]) }, index: { data: new Uint16Array([0, 1, 2, 0, 2, 3]) }, });
const program = new Program(gl, { vertex: billboardVertex, fragment: billboardFragment, uniforms: { tMap: { value: texture } }, transparent: true, depthWrite: false, cullFace: false, });
return new Mesh(gl, { geometry, program }); }
// Real-unit values at the +/-1 ends of each axis. Unlike buildAxes/ // buildGrid, this DOES depend on data (the scales), so it's rebuilt // by an effect below whenever a column or its scale changes, rather // than being built once in onMount. function buildAxisLabels( xScale: ReturnType<typeof createScale>, yScale: ReturnType<typeof createScale>, zScale: ReturnType<typeof createScale>, xUnit: string, yUnit: string, zUnit: string, ): Transform { const group = new Transform(); // Same clearance on both ends, measured from each axis's actual // tip: the flat end at -1 on the negative side, and the arrowhead // tip (axisHeadLen past +1) on the positive side. Without this, // the positive labels looked further away since the arrowhead // pushes their reference point outward. const gap = 0.1; const negOffset = -(1 + gap); const posOffset = 1 + axisHeadLen + gap; // The Y axis stays screen-vertical regardless of orbit rotation // (unlike X/Z, which rotate with the camera), so its +Y label // reads as noticeably further from the tip than X/Z at the same // world-space gap. Pull it in specifically to compensate. const yPosOffset = 1 + axisHeadLen + gap * 0.3;
function addLabel( value: number, unit: string, color: string, pos: [number, number, number], ) { const text = `${value.toFixed(2)}${unit ? " " + unit : ""}`; const sprite = makeTextSprite(text, color); sprite.position.set(pos[0], pos[1], pos[2]); sprite.setParent(group); }
addLabel(xScale.toReal(-1), xUnit, "#ff3333", [negOffset, 0, 0]); addLabel(xScale.toReal(1), xUnit, "#ff3333", [posOffset, 0, 0]);
addLabel(yScale.toReal(-1), yUnit, "#33cc33", [0, negOffset, 0]); addLabel(yScale.toReal(1), yUnit, "#33cc33", [0, yPosOffset, 0]);
addLabel(zScale.toReal(-1), zUnit, "#3399ff", [0, 0, negOffset]); addLabel(zScale.toReal(1), zUnit, "#3399ff", [0, 0, posOffset]);
return group; }
// A thin box instead of a flat plane, so the slice actually shows its // thickness. The box geometry has a fixed depth of 1; the *mesh's* // z-scale is what stretches it to the real slice width (see the effect // below), so changing sliceWidth never requires rebuilding geometry. function buildSliceSlab(): Mesh { const geometry = new Box(gl, { width: 2, height: 2, depth: 1 }); const program = new Program(gl, { vertex: solidColorVertex, fragment: solidColorAlphaFragment, uniforms: { uColor: { value: new Color(0.933, 0.51, 0.933) }, // #ee82ee uAlpha: { value: 0.3 }, }, transparent: true, depthWrite: false, cullFace: false, // DoubleSide equivalent }); return new Mesh(gl, { geometry, program }); }
// The single place where derived state (points3D) turns into 3D // geometry. No calculation happens here, just copying. // // bv/indexColor mirror the same idea as TwoPlot's visualMap: when // indexColor is on, each point gets its virtual star color from its // Gaia B-V (bp_rp); otherwise every point is plain black. Neither // three.js nor OGL has a visualMap equivalent, so this is done // manually with a per-vertex "color" attribute read by pointsVertex. function updatePointCloud(points: Points3D, colorList: number[]) { if (pointCloud) { disposeNode(pointCloud); }
const n = points.xs.length; const positions = new Float32Array(n * 3); const colors = new Float32Array(n * 3); const tmpColor = new Color(); for (let i = 0; i < n; i++) { positions[i * 3] = points.xs[i]; positions[i * 3 + 1] = points.ys[i]; positions[i * 3 + 2] = points.zs[i];
tmpColor.set( proyectState.current!.color >= 0 && Number.isFinite(colorList[i]) ? getColor( colorList[i], PAINTS[proyectState.current!.color].colorRGB, ) : 0x000000, ); colors[i * 3] = tmpColor.r; colors[i * 3 + 1] = tmpColor.g; colors[i * 3 + 2] = tmpColor.b; }
const geometry = new Geometry(gl, { position: { size: 3, data: positions }, color: { size: 3, data: colors }, });
pointsProgram = new Program(gl, { vertex: pointsVertex, fragment: pointsFragment, uniforms: { uSize: { value: ThreePlotState.pointSize }, uHeight: { value: gl.canvas.height }, uFov: { value: (camera.fov * Math.PI) / 180 }, }, });
pointCloud = new Mesh(gl, { geometry, program: pointsProgram, mode: gl.POINTS, }); pointCloud.setParent(scene); }
function handleResize() { if (!renderer || !camera || !container) return; const w = container.clientWidth; const h = container.clientHeight; if (w > 0 && h > 0) { renderer.setSize(w, h); camera.perspective({ aspect: w / h }); } }
onMount(() => { renderer = new Renderer({ width: container.clientWidth, height: container.clientHeight || 1, dpr: Math.min(window.devicePixelRatio || 1, 2), antialias: true, alpha: false, }); gl = renderer.gl; gl.clearColor(1, 1, 1, 1); container.appendChild(gl.canvas);
camera = new Camera(gl); camera.perspective({ fov: 60, near: 0.01, far: 100, aspect: container.clientWidth / (container.clientHeight || 1), }); // Restore the last camera view if we saved one (e.g. coming back // from another tab); otherwise fall back to the default framing. if (ThreePlotState.cameraPosition) { camera.position.set( ThreePlotState.cameraPosition.x, ThreePlotState.cameraPosition.y, ThreePlotState.cameraPosition.z, ); } else { camera.position.set(-2, 1, 2); }
const target = new Vec3(0, 0, 0); if (ThreePlotState.cameraTarget) { target.set( ThreePlotState.cameraTarget.x, ThreePlotState.cameraTarget.y, ThreePlotState.cameraTarget.z, ); } camera.lookAt(target);
controls = new Orbit(camera, { element: gl.canvas, target });
scene = new Transform();
axesHelper = buildAxes(); axesHelper.setParent(scene);
// Populated by the effect below (it depends on xScale/yScale/ // zScale, which aren't guaranteed ready at this exact point). axisLabels = new Transform(); axisLabels.setParent(scene);
gridHelper = buildGrid(); gridHelper.setParent(scene);
sliceSlab = buildSliceSlab(); sliceSlab.position.z = ThreePlotState.slicePos; sliceSlab.scale.z = ThreePlotState.sliceWidth * 2; sliceSlab.setParent(scene);
updatePointCloud(points3D, bvValues);
let animId: number; function animate() { animId = requestAnimationFrame(animate); controls.update(); // Point size attenuation depends on the current canvas height // (see pointsVertex), so keep it live across resizes without // needing to rebuild the whole point cloud. if (pointsProgram) { pointsProgram.uniforms.uHeight.value = gl.canvas.height; } renderer.render({ scene, camera }); } animate();
window.addEventListener("resize", handleResize); resizeObserver = new ResizeObserver(() => handleResize()); resizeObserver.observe(container);
return () => { // Snapshot the final camera view so the next time this // component mounts (e.g. switching back to this tab) it picks // up right where it was left, instead of resetting. ThreePlotState.cameraPosition = { x: camera.position.x, y: camera.position.y, z: camera.position.z, }; ThreePlotState.cameraTarget = { x: controls.target.x, y: controls.target.y, z: controls.target.z, };
cancelAnimationFrame(animId); window.removeEventListener("resize", handleResize); resizeObserver.disconnect(); controls.remove();
disposeNode(pointCloud); disposeNode(axesHelper); disposeNode(axisLabels); disposeNode(gridHelper); disposeNode(sliceSlab);
// OGL has no renderer.dispose(): explicitly losing the WebGL // context is the recommended way to release GPU resources it // doesn't track itself (the default framebuffer, VAOs, etc). gl.getExtension("WEBGL_lose_context")?.loseContext(); container.removeChild(gl.canvas); }; });
// Sync: derived state -> scene. These two effects are the only bridge // between the data layer and OGL. // // Note: the dependency (points3D / slicePos) is read unconditionally at // the top of each effect, before the "if" — that way Svelte always // registers the dependency, even if the scene doesn't exist yet on the // first render. $effect(() => { const points = points3D; const bv = bvValues; if (scene) updatePointCloud(points, bv); });
$effect(() => { const xs = xScale; const ys = yScale; const zs = zScale; const xUnit = xMeta!.unit ?? ""; const yUnit = yMeta!.unit ?? ""; const zUnit = zMeta!.unit ?? ""; if (scene) { if (axisLabels) { disposeNode(axisLabels); } axisLabels = buildAxisLabels(xs, ys, zs, xUnit, yUnit, zUnit); axisLabels.setParent(scene); } });
$effect(() => { const pos = ThreePlotState.slicePos; const halfWidth = ThreePlotState.sliceWidth; if (sliceSlab) { sliceSlab.position.z = pos; // Box geometry's z-depth is 1, so scale.z = halfWidth * 2 makes // the box's actual thickness match the slice's full width. sliceSlab.scale.z = halfWidth * 2; } });
$effect(() => { const visible = ThreePlotState.sliceToggle; if (sliceSlab) sliceSlab.visible = visible; });</script>
<div class="scatter3d"> <div class="controls"> <label class="x" >X: <select bind:value={ThreePlotState.xColumn} class="x-select"> {#each columnNames as header, i} {@const headerMeta = proyectState.current!.data.meta([ header, ])} {#if headerMeta} {#if headerMeta.type === "number"} <option value={i} >{header} {#if headerMeta.unit} ({headerMeta.unit}) {/if} </option> {/if} {/if} {/each} </select> </label> <label class="y" >Y: <select bind:value={ThreePlotState.yColumn} class="y-select"> {#each headers()! as header, i} {@const headerMeta = proyectState.current!.data.meta([ header, ])} {#if headerMeta} {#if headerMeta.type === "number"} <option value={i} >{header} {#if headerMeta.unit} ({headerMeta.unit}) {/if} </option> {/if} {/if} {/each} </select> </label> <label class="z" >Z: <select bind:value={ThreePlotState.zColumn} class="z-select"> {#each headers()! as header, i} {@const headerMeta = proyectState.current!.data.meta([ header, ])} {#if headerMeta} {#if headerMeta.type === "number"} <option value={i} >{header} {#if headerMeta.unit} ({headerMeta.unit}) {/if} </option> {/if} {/if} {/each} </select> </label> <label for="slice-toggle">Slice</label> <input id="slice-toggle" type="checkbox" bind:checked={ThreePlotState.sliceToggle} /> {#if ThreePlotState.sliceToggle} <label class="range-label"> Slice Position: <input type="range" min="-1" max="1" step="0.01" bind:value={ThreePlotState.slicePos} /> <span >{sliceRealPos.toFixed(2)} {#if zMeta} {zMeta.unit} {/if} </span> </label> <label class="range-label"> Slice Width: <input type="range" min="0.01" max="0.5" step="0.01" bind:value={ThreePlotState.sliceWidth} /> <span >±{sliceRealWidth.toFixed(2)}
{#if zMeta} {zMeta.unit} {/if} </span> </label> {/if} <label for="point-size">Point Size</label> <input id="point-size" type="range" min="0.005" max="0.03" step="0.001" bind:value={ThreePlotState.pointSize} /> </div>
<div class="wrapper"> <div bind:this={container} class="container"> {#if ThreePlotState.sliceToggle} <div class="slice"> <SlicePlot data={sliceData} color="#000000" /> </div> {/if} </div> </div></div>
<style> .scatter3d { display: flex; flex-direction: column; height: 100%; width: 100%; }
.controls { display: flex; flex-wrap: wrap; gap: 1.5em; padding: var(--padding); border-bottom: 2px solid var(--text); background: var(--surface); flex-shrink: 0; }
label { display: flex; align-items: center; gap: 0.5em; font-size: 0.85em; color: var(--textMuted); }
.x { color: var(--red); font-weight: bold; } .x-select { border-color: var(--red); }
.y { color: var(--green); font-weight: bold; } .y-select { border-color: var(--green); }
.z { color: var(--blue); font-weight: bold; } .z-select { border-color: var(--blue); }
select { font-family: "JetBrains Mono", monospace; font-size: 0.85em; background: var(--bg); color: var(--text); border: 2px solid var(--text); border-radius: 4px; padding: 2px 6px; cursor: pointer; outline: none; }
select:focus { border-color: var(--blue); color: var(--blue); }
.range-label input[type="range"] { accent-color: var(--blue); cursor: pointer; }
.range-label span { min-width: 3ch; white-space: nowrap; }
.container { position: relative; overflow: hidden; width: 100%; height: 100%; }
.wrapper { flex: 1; min-height: 0; }
.container :global(canvas) { display: block; }
.slice { position: absolute; top: 12px; right: 12px; width: 300px; height: 300px; border: 4px solid var(--magenta); border-radius: 6px; background: rgba(255, 255, 255, 0.92); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); }
@media (max-width: 600px) { .slice { width: 160px; height: 130px; } }</style>