Something went wrong. Try again.
svelte astro
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544<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. Three.js 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.
import { getColor, type CSVData } from "../utils"; import { onMount } from "svelte"; import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import SlicePlot from "./SlicePlot.svelte"; import { PAINTS, proyectState, ThreePlotState } from "../State.svelte"; import { getColumns, createScale, normalizePoints, sliceAt, type Points3D, } from "../three-plot";
let { csv, onready }: { csv: CSVData; onready?: () => void } = $props();
$effect(() => { if (!ThreePlotState.xColumn) { ThreePlotState.xColumn = csv.col(["ra", "ra_icrs"]); } if (!ThreePlotState.yColumn) { ThreePlotState.yColumn = csv.col(["dec", "de_icrs"]); } if (!ThreePlotState.zColumn) { ThreePlotState.zColumn = csv.col(["plx", "parallax"]); } });
// 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 bvColumn 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 -1; // O el índice por defecto de bp-rp } return csv.col([PAINTS[activeProject.color].columnName]); });
let [xValues, yValues, zValues, bvValues] = $derived( getColumns(csv, [ ThreePlotState.xColumn as number, ThreePlotState.yColumn as number, ThreePlotState.zColumn as number, 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; let scene: THREE.Scene; let camera: THREE.PerspectiveCamera; let renderer: THREE.WebGLRenderer; let controls: OrbitControls; let pointCloud: THREE.Points; let axesHelper: THREE.Group; let gridHelper: THREE.GridHelper; let sliceSlab: THREE.Mesh; let resizeObserver: ResizeObserver;
function disposeObject3D(obj: THREE.Object3D | undefined | null) { if (!obj) return; obj.traverse((child) => { const mesh = child as THREE.Mesh; if (mesh.geometry) mesh.geometry.dispose(); if (mesh.material) { const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; mats.forEach((m) => m.dispose()); } }); }
// 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(): THREE.Group { const axes = new THREE.Group(); const r = 0.006; // thickness const len = 2;
const mats = [ new THREE.MeshBasicMaterial({ color: 0xff3333 }), // X red new THREE.MeshBasicMaterial({ color: 0x33cc33 }), // Y green new THREE.MeshBasicMaterial({ color: 0x3399ff }), // Z blue ];
const xMesh = new THREE.Mesh( new THREE.CylinderGeometry(r, r, len, 8), mats[0], ); xMesh.rotation.z = -Math.PI / 2; axes.add(xMesh);
const yMesh = new THREE.Mesh( new THREE.CylinderGeometry(r, r, len, 8), mats[1], ); axes.add(yMesh);
const zMesh = new THREE.Mesh( new THREE.CylinderGeometry(r, r, len, 8), mats[2], ); zMesh.rotation.x = Math.PI / 2; axes.add(zMesh);
return axes; }
function buildGrid(): THREE.GridHelper { return new THREE.GridHelper(2, 20, 0x000000, 0xd8d8d0); }
// 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(): THREE.Mesh { return new THREE.Mesh( new THREE.BoxGeometry(2, 2, 1), new THREE.MeshBasicMaterial({ color: 0xee82ee, transparent: true, opacity: 0.3, side: THREE.DoubleSide, }), ); }
// 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. Three.js has // no visualMap equivalent, so this is done manually with a per-vertex // "color" BufferAttribute + vertexColors on the material. function updatePointCloud(points: Points3D, colorList: number[]) { if (pointCloud) { scene.remove(pointCloud); disposeObject3D(pointCloud); }
const n = points.xs.length; const positions = new Float32Array(n * 3); const colors = new Float32Array(n * 3); const tmpColor = new THREE.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 THREE.BufferGeometry(); geometry.setAttribute( "position", new THREE.BufferAttribute(positions, 3), ); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({ color: 0xffffff, // left white so it doesn't tint the per-vertex color vertexColors: true, size: 0.015, sizeAttenuation: true, });
pointCloud = new THREE.Points(geometry, material); scene.add(pointCloud); }
function handleResize() { if (!renderer || !camera || !container) return; const w = container.clientWidth; const h = container.clientHeight; if (w > 0 && h > 0) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); } }
onMount(() => { scene = new THREE.Scene(); scene.background = new THREE.Color(0xffffff);
camera = new THREE.PerspectiveCamera( 60, container.clientWidth / container.clientHeight, 0.01, 100, ); // 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); }
renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; if (ThreePlotState.cameraTarget) { controls.target.set( ThreePlotState.cameraTarget.x, ThreePlotState.cameraTarget.y, ThreePlotState.cameraTarget.z, ); controls.update(); }
axesHelper = buildAxes(); scene.add(axesHelper);
gridHelper = buildGrid(); scene.add(gridHelper);
sliceSlab = buildSliceSlab(); sliceSlab.position.z = ThreePlotState.slicePos; sliceSlab.scale.z = ThreePlotState.sliceWidth * 2; scene.add(sliceSlab);
updatePointCloud(points3D, bvValues); onready?.();
let animId: number; function animate() { animId = requestAnimationFrame(animate); controls.update(); 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(); disposeObject3D(pointCloud); disposeObject3D(axesHelper); disposeObject3D(gridHelper); disposeObject3D(sliceSlab); renderer.dispose(); }; });
// Sync: derived state -> scene. These two effects are the only bridge // between the data layer and Three.js. // // 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 pos = ThreePlotState.slicePos; const halfWidth = ThreePlotState.sliceWidth; if (sliceSlab) { sliceSlab.position.z = pos; // BoxGeometry'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 csv.headers as col, i}<option value={i}>{col}</option >{/each} </select> </label> <label class="y" >Y: <select bind:value={ThreePlotState.yColumn} class="y-select"> {#each csv.headers as col, i}<option value={i}>{col}</option >{/each} </select> </label> <label class="z" >Z: <select bind:value={ThreePlotState.zColumn} class="z-select"> {#each csv.headers as col, i}<option value={i}>{col}</option >{/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)} {csv.columnMeta.get(ThreePlotState.zColumn as number) ?.unit ?? ""}</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)} {csv.columnMeta.get(ThreePlotState.zColumn as number) ?.unit ?? ""}</span > </label> {/if} </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>