// 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. import type { CSVData } from "./utils"; /** Converts a CSV 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): boolean { // A missing column (-1, e.g. an optional field like bp_rp that isn't // present in this CSV) 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 < 0) return true; const cell = row[colIndex]; return cell !== undefined && cell.trim() !== ""; } /** * Extracts several columns of the CSV 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. * * csv.rows is row-major (each row is an array indexed by column), so * "column N" is always obtained by walking the rows and reading row[N] — * never csv.rows[N] directly. That mix-up was the source of the real-unit * bug. */ export function getColumns(csv: CSVData, colIndices: number[]): number[][] { const validRows = csv.rows.filter((row) => colIndices.every((c) => isValidCell(row, c)), ); return colIndices.map((c) => validRows.map((row) => (c < 0 ? NaN : toNumber(row[c]))), ); } /** Extracts one column of the CSV as numbers, dropping rows empty in that column. */ export function getColumn(csv: CSVData, colIndex: number): number[] { return getColumns(csv, [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; }