import { parse, type ParseResult } from "@elekcsv/core"; /// COLUMN INDEX /** Maps normalized column names to their index in a CSV row */ export type ColumnIndex = Map; const normalizeHeader = (h: string): string => h.trim().toLowerCase(); /** Builds a lookup index mapping header names to their column position. * Keeps the first occurrence when there are duplicate headers. */ export function buildColumnIndex(headers: string[]): ColumnIndex { const index: ColumnIndex = new Map(); headers.forEach((h, i) => { const key = normalizeHeader(h); if (!index.has(key)) index.set(key, i); }); return index; } /** Column index for a given name, or -1 if not found */ export function col(index: ColumnIndex, name: string): number { return index.get(normalizeHeader(name)) ?? -1; } /** * Tries each candidate header name in order and returns the first one * present in this CSV */ export function colAny(index: ColumnIndex, names: string[]): number { for (const name of names) { const i = col(index, name); if (i !== -1) return i; } return -1; } /// STAR COLOR /** Color stops for the stellar color sequence, hot blue to cool red/brown * dwarfs, expressed directly in BP-RP units. */ export type ColorStops = { stop: number; color: [number, number, number] } /** Returns a vivid rgb hex color for a BP-RP (or B-V) color index */ // utils.ts export function getColor(val: number, colorStops: ColorStops[]): string { if (!colorStops || colorStops.length === 0) return "#000000"; const min = colorStops[0].stop; const max = colorStops[colorStops.length - 1].stop; const x = Math.min(Math.max(val, min), max); // Buscar el segmento let lower = colorStops[0]; let upper = colorStops[colorStops.length - 1]; for (let i = 0; i < colorStops.length - 1; i++) { if (x >= colorStops[i].stop && x <= colorStops[i + 1].stop) { lower = colorStops[i]; upper = colorStops[i + 1]; break; } } const range = upper.stop - lower.stop; const t = range === 0 ? 0 : (x - lower.stop) / range; const lerp = (a: number, b: number) => Math.round(a + (b - a) * t); const toHex = (v: number) => v.toString(16).padStart(2, "0"); return `#${toHex(lerp(lower.color[0], upper.color[0]))}${toHex(lerp(lower.color[1], upper.color[1]))}${toHex(lerp(lower.color[2], upper.color[2]))}`; } /// COLUMN METADATA (generated from the parsed file) /** Everything we know about a single column of a loaded CSV. */ export type ColumnMeta = { /** The header exactly as it appears in the file */ name: string; /** Value type, inferred from the file's own data */ type: "number" | "string"; /** From the source file when it carries one, else a prettified `name` */ description: string; /** e.g. "mas", "deg". `undefined` when the file didn't carry one */ unit?: string; }; /** Currently supported VizieR formats store metadata with '#' */ const isCommentLine = (line: string): boolean => line.startsWith("#") || line.startsWith("\\"); const stripCommentPrefix = (line: string): string => line.replace(/^[#\\]\s?/, ""); /** VizieR's dashed column-width separator */ function isSeparatorLine(line: string): boolean { const bare = line.trim(); return bare.length > 0 && /^[\s\-|;,\t]+$/.test(bare) && bare.includes("-"); } /** Turns "phot_bp_mean_mag" into "Phot Bp Mean Mag" */ function prettifyName(name: string): string { return name .replace(/[_\-]+/g, " ") .trim() .replace(/\b\w/g, (c) => c.toUpperCase()); } /** Infers a column's type from a sample value. Long all-digit strings are * kept as strings on purpose: parsing them as floats silently loses precision. */ function inferType(sample: string | undefined): "number" | "string" { if (sample === undefined || sample === "") return "string"; if (/^\d{16,}$/.test(sample)) return "string"; return Number.isNaN(parseFloat(sample)) ? "string" : "number"; } const DELIMITER_CANDIDATES = [",", ";", "\t", "|"]; /** Picks whichever candidate delimiter shows up most often in the header line */ function detectDelimiter(headerLine: string): string { let best = DELIMITER_CANDIDATES[0]; let bestCount = 0; for (const d of DELIMITER_CANDIDATES) { const count = headerLine.split(d).length - 1; if (count > bestCount) { best = d; bestCount = count; } } return best; } /** True when every non-empty cell looks like a short unit token */ function looksLikeUnitsRow(cells: string[]): boolean { const nonEmpty = cells.filter((c) => c.length > 0); return nonEmpty.length > 0 && nonEmpty.every((c) => !/\s/.test(c) && c.length <= 12); } /** Reads an optional '#'-prefixed metadata row, returning its cells only if * the line is a comment that splits into exactly `expected` cells. */ function commentRowCells(line: string | undefined, delimiter: string, expected: number): string[] | null { if (line === undefined || !isCommentLine(line)) return null; const cells = stripCommentPrefix(line).split(delimiter).map((c) => c.trim()); return cells.length === expected ? cells : null; } /** * Splits raw catalog text into what a plain CSV parser needs (`body`, * `delimiter`) plus whatever per-column metadata the file itself carried. * Transparently handles plain local CSVs, VizieR-style exports (an optional * units row, an optional description row, an optional dashed separator — * each only consumed if it actually looks like what it claims to be), and * anything in between. */ function extractCatalogMeta(text: string): { meta: Map; body: string; delimiter: string } { const lines = text.split(/\r\n|\n/); let i = 0; while (i < lines.length && lines[i].trim() === "") i++; if (i >= lines.length) return { meta: new Map(), body: text, delimiter: "," }; const headerLine = stripCommentPrefix(lines[i]); const delimiter = detectDelimiter(headerLine); const headers = headerLine.split(delimiter).map((h) => h.trim()); i++; const meta = new Map(); headers.forEach((name, idx) => meta.set(idx, { name, type: "string", description: prettifyName(name) })); const unitCells = commentRowCells(lines[i], delimiter, headers.length); if (unitCells && looksLikeUnitsRow(unitCells)) { unitCells.forEach((unit, idx) => { if (unit) meta.get(idx)!.unit = unit; }); i++; } const descriptionCells = commentRowCells(lines[i], delimiter, headers.length); if (descriptionCells && descriptionCells.some((c) => c.length > 0)) { descriptionCells.forEach((description, idx) => { if (description) meta.get(idx)!.description = description; }); i++; } if (i < lines.length && isSeparatorLine(lines[i])) i++; const body = [headers.join(delimiter), ...lines.slice(i)].join("\n"); return { meta, body, delimiter }; } /// MAIN DATA /** CSV parse result extended with a column index and per-column metadata, * generated fresh from the file, never hardcoded. */ export type CSVData = ParseResult & { columnIndex: ColumnIndex; columnMeta: Map, col: (names: string[]) => number }; /** * Parses raw CSV text into structured data with headers, a column index, and * per-column metadata. Understands VizieR-style exports as well as plain * local CSVs transparently — same function either way. */ export function parseCSV(text: string): CSVData { const { meta, body, delimiter } = extractCatalogMeta(text); const result = parse(body, { header: true, delimiter }); const columnIndex = buildColumnIndex(result.headers ?? []); const firstRow = result.rows[0]; for (const [idx, def] of meta) { def.type = inferType(firstRow?.[idx]); } return { ...result, columnIndex, columnMeta: meta, col: (names: string[]) => colAny(columnIndex, names) }; } function metaFor(csv: CSVData, name: string): ColumnMeta | undefined { const i = col(csv.columnIndex, name); return i !== -1 ? csv.columnMeta.get(i) : undefined; } /** Human description for a column, from the source file's own metadata when * it carried one, otherwise a prettified version of the column name. */ export function columnDescription(csv: CSVData, name: string): string { return metaFor(csv, name)?.description ?? name; } /** Unit string for a column (e.g. "mas", "deg"), from the source file's own * metadata. `undefined` when the file didn't carry one. */ export function columnUnit(csv: CSVData, name: string): string | undefined { return metaFor(csv, name)?.unit; } /** Ready-to-display label for a column, e.g. "Parallax (mas)". Omits the * unit suffix entirely when there isn't one. */ export function columnLabel(csv: CSVData, name: string): string { const description = columnDescription(csv, name); const unit = columnUnit(csv, name); return unit ? `${description} (${unit})` : description; } /** Reads and parses a single column's value from a row, applying the type * inferred for that column when the file was parsed. */ export function readValue(csv: CSVData, row: string[], name: string): number | string | null { const i = col(csv.columnIndex, name); if (i === -1 || row[i] === undefined || row[i] === "") return null; const meta = csv.columnMeta.get(i); if (meta?.type === "number") { const parsed = parseFloat(row[i]); return Number.isNaN(parsed) ? null : parsed; } return row[i]; } export type RowValues = Record; /** Reads every column's value for a given row, keyed by the column's real name. */ export function readRow(csv: CSVData, rowIndex: number): RowValues | null { const row = csv.rows[rowIndex]; if (!row) return null; const values: RowValues = {}; for (const meta of csv.columnMeta.values()) { values[meta.name] = readValue(csv, row, meta.name); } return values; } /** Finds the row (star) whose values in two given columns exactly match the * target values. Returns null if either column is missing or nothing matches. */ export function findStarExact( csv: CSVData, columnA: string, valueA: string | number, columnB: string, valueB: string | number, ): number | null { const iA = col(csv.columnIndex, columnA); const iB = col(csv.columnIndex, columnB); if (iA === -1 || iB === -1) return null; const rowIndex = csv.rows.findIndex((row) => row[iA] === String(valueA) && row[iB] === String(valueB)); return rowIndex === -1 ? null : rowIndex; } /// UTILITIES /** Picks a random tooltip string from a list of options */ export function randomTooltip(tooltips: string[]): string { return tooltips[Math.floor(Math.random() * tooltips.length)]; }