/** Picks a random tooltip string from a list of options */ export function randomTooltip(tooltips: string[]): string { return tooltips[Math.floor(Math.random() * tooltips.length)]; } /** Minimal RFC4180-style split: handles quoted fields that contain the * delimiter or escaped quotes (""). Good enough for CSV exports that * quote string columns (e.g. a designation column with a comma in it). */ export function splitCSVLine(line: string): string[] { const result: string[] = []; let cur = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (inQuotes) { if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQuotes = false; } else { cur += c; } } else { if (c === '"') inQuotes = true; else if (c === ",") { result.push(cur.trim()); cur = ""; } else cur += c; } } result.push(cur.trim()); return result; } /** * FORTRAN-style format codes VizieR uses: F/E/D = floating point, I = * integer, A/a = text -- so the first letter alone tells us the type. * * NB: some "numeric" columns don't round-trip safely through JS numbers, * most notably Gaia's `Source` id (format I19, up to 19 digits) which * exceeds Number.MAX_SAFE_INTEGER. Values are always kept as raw strings * in `mainData`, so nothing is lost here -- just be careful not to pipe * an id column through Number()/parseFloat() downstream if you need * exact equality; compare the strings (or use BigInt) instead. */ function classifyFormat(format: string): "number" | "string" { const letter = format[0]?.toUpperCase(); return letter === "F" || letter === "E" || letter === "D" || letter === "I" ? "number" : "string"; } /** * Reads the `#Column NAME (FORMAT) DESCRIPTION [ucd=...]` lines VizieR * puts above the table. These are always tab-separated regardless of the * table's own delimiter, which is what makes them a reliable source for * descriptions and types even when we're not sure how the table below * is delimited yet. */ export function parseColumnComments(rawRows: string[]): Map { const docs = new Map(); for (const line of rawRows) { if (!line.startsWith("#Column\t")) continue; const parts = line.split("\t"); if (parts.length < 4) continue; const name = parts[1].trim(); const format = parts[2].trim().replace(/^\(|\)$/g, ""); let description = parts[3].trim(); if (description.startsWith("?")) description = description.slice(1).trim(); // "?" marks a nullable field docs.set(name, { description, type: classifyFormat(format) }); } return docs; } /** A column is "number" only if every non-empty value in it parses as * a finite number; empty/missing values don't disqualify it. */ export function inferType(values: string[]): "number" | "string" { const nonEmpty = values.map(v => v.trim()).filter(v => v.length > 0); if (nonEmpty.length === 0) return "string"; return nonEmpty.every(v => Number.isFinite(Number(v))) ? "number" : "string"; } /** Picks whichever candidate splits the header row into the most fields. */ export function detectDelimiter(headerLine: string): string { const candidates = ["\t", "|", ";", ","]; let best = candidates[0]; let bestCount = 1; for (const d of candidates) { const count = headerLine.split(d).length; if (count > bestCount) { bestCount = count; best = d; } } return best; } /** True for VizieR's "-------\t-------\t..." column-width separator row. */ export function isDashRow(line: string): boolean { return line.includes("-") && /^[-\s|;,\t]+$/.test(line); }