// Pure data/math layer for the 3D scatter plot. // // Nothing in here knows Three.js or Svelte exist — these are plain // functions that can be read and tested in isolation. Everything the // component needs to "calculate" lives here; the component just consumes it. /** Converts a cell (string, number, whatever) to a number. */ function toNumber(value: unknown): number { return typeof value === "number" ? value : parseFloat(String(value)); } function isValidCell(row: string[], colIndex: number | null): boolean { // A missing column (null, e.g. an optional field like bp_rp that isn't // present in this file) shouldn't disqualify a row — there's nothing to // check, so it's treated as valid and toNumber() will just produce NaN // for it below. if (colIndex === null) return true; const cell = row[colIndex]; return cell !== undefined && cell.trim() !== ""; } /** * Extracts several columns as numbers, filtered TOGETHER: a row is dropped * if ANY of the requested columns is empty for that row. * * This is the version to use whenever the resulting arrays need to line up * by index (e.g. x/y/z/color for the same set of points) — filtering each * column separately (as a naive per-column filter would) drops different * rows from each array, so index i silently stops referring to the same * row across arrays. That's what caused stars with no parallax to still * show up: they were dropped from the z (parallax) array but not from x/y, * shifting everything after them out of alignment. * * Takes `rows` (row-major, each row indexed by column) rather than a full * Data object — that way the caller decides which rows are in play (e.g. * only the ones passing the active filters) instead of this layer silently * always using every row. * * colIndices come from Data.col(...) and are therefore number | null: * null means the column wasn't found in this file (see isValidCell above). */ export function getColumns(rows: string[][], colIndices: (number | null)[]): number[][] { const validRows = rows.filter((row) => colIndices.every((c) => isValidCell(row, c)), ); return colIndices.map((c) => validRows.map((row) => (c === null ? NaN : toNumber(row[c]))), ); } /** Extracts one column as numbers, dropping rows empty in that column. */ export function getColumn(rows: string[][], colIndex: number | null): number[] { return getColumns(rows, [colIndex])[0]; } /** * Linear min/max scale for a column. It's the bridge between that column's * real-world units and the normalized [-1, 1] space the 3D scene lives in. * toNormalized() and toReal() are inverses of each other, computed once * from the same min/max — by construction they can't drift apart. */ export interface Scale { min: number; max: number; range: number; /** real value -> normalized [-1, 1] space */ toNormalized(value: number): number; /** normalized [-1, 1] space -> real value */ toReal(normalized: number): number; } export function createScale(values: number[]): Scale { const valid = values.filter(Number.isFinite); const min = valid.length ? Math.min(...valid) : 0; const max = valid.length ? Math.max(...valid) : 0; const range = max - min; return { min, max, range, toNormalized(value) { if (!Number.isFinite(value) || range === 0) return 0; return ((value - min) / range) * 2 - 1; }, toReal(normalized) { return ((normalized + 1) / 2) * range + min; }, }; } /** The three columns already normalized to [-1, 1], ready for a BufferGeometry. */ export interface Points3D { xs: Float32Array; ys: Float32Array; zs: Float32Array; } export function normalizePoints( xValues: number[], yValues: number[], zValues: number[], xScale: Scale, yScale: Scale, zScale: Scale, ): Points3D { const n = xValues.length; const xs = new Float32Array(n); const ys = new Float32Array(n); const zs = new Float32Array(n); for (let i = 0; i < n; i++) { xs[i] = xScale.toNormalized(xValues[i]); ys[i] = yScale.toNormalized(yValues[i]); zs[i] = zScale.toNormalized(zValues[i]); } return { xs, ys, zs }; } /** * Points whose normalized Z falls within [center - halfWidth, center + halfWidth], * returned as [x, y] pairs ready for the SlicePlot. */ export function sliceAt( points: Points3D, center: number, halfWidth: number, ): [number, number][] { const result: [number, number][] = []; for (let i = 0; i < points.zs.length; i++) { if (Math.abs(points.zs[i] - center) <= halfWidth) { result.push([points.xs[i], points.ys[i]]); } } return result; }