@
Add star-map Angular app, ETL pipeline, and caveman plugin Angular 3D star map (galaxy/system/body views, Three.js rendering, navigation store) plus the NASA ETL tooling that builds the star, exoplanet and solar-system datasets, Playwright e2e suite, and the cs:caveman Claude Code plugin (command, agent, skill). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Minimal RFC4180-ish CSV parser: supports a custom delimiter, quoted fields (with escaped
|
||||
* `""`), and embedded newlines inside quotes. Good enough for the HYG/Exoplanet Archive
|
||||
* exports used by the ETL, without pulling in an extra dependency.
|
||||
*/
|
||||
export function parseCsv(text: string, delimiter = ','): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === delimiter) {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (char === '\r') {
|
||||
// skip; \n (handled below) terminates the row
|
||||
} else if (char === '\n') {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Parses `text` as CSV and maps each data row to an object keyed by the header row. */
|
||||
export function parseCsvObjects(text: string, delimiter = ','): Array<Record<string, string>> {
|
||||
const rows = parseCsv(text, delimiter).filter((row) => row.some((cell) => cell.length > 0));
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [header, ...dataRows] = rows;
|
||||
return dataRows.map((row) => {
|
||||
const record: Record<string, string> = {};
|
||||
header.forEach((column, index) => {
|
||||
record[column] = row[index] ?? '';
|
||||
});
|
||||
return record;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { OrbitalElements } from '../../../src/app/shared/models/body.model';
|
||||
import { fetchTextCached } from './http';
|
||||
|
||||
const HORIZONS_URL = 'https://ssd.jpl.nasa.gov/api/horizons.api';
|
||||
const KM_PER_AU = 149597870.7;
|
||||
|
||||
// Fixed reference epoch: keeps the ETL output (and its cache) stable across reruns,
|
||||
// consistent with the "bake once at build time" ETL philosophy.
|
||||
const REFERENCE_START = '2025-01-01';
|
||||
const REFERENCE_STOP = '2025-01-02';
|
||||
|
||||
export interface HorizonsQuery {
|
||||
/** Horizons body id, e.g. `'499'` for Mars. */
|
||||
command: string;
|
||||
/** Horizons coordinate center, e.g. `'500@10'` (Sun) or `'500@399'` (Earth). */
|
||||
center: string;
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
export interface HorizonsResult {
|
||||
radiusKm?: number;
|
||||
orbit: OrbitalElements;
|
||||
}
|
||||
|
||||
const RADIUS_PATTERNS = [
|
||||
/Vol\.?\s*mean\s*radius[^=]*=\s*([\d.]+)/i,
|
||||
/Mean\s*radius[^=]*=\s*([\d.]+)/i,
|
||||
/Radius\s*\(IAU\)[^=]*=\s*([\d.]+)/i,
|
||||
/Radius,?\s*\(km\)\s*=\s*([\d.]+)/i,
|
||||
/Radius\s*\(gravity\),?\s*km\s*=\s*([\d.]+)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Queries JPL Horizons for a body's heliocentric (or planetocentric, for moons) osculating
|
||||
* orbital elements plus, when available, its mean physical radius — both in a single
|
||||
* request (`OBJ_DATA=YES` + `EPHEM_TYPE=ELEMENTS`).
|
||||
*/
|
||||
export async function fetchHorizonsBody(query: HorizonsQuery): Promise<HorizonsResult> {
|
||||
const url =
|
||||
`${HORIZONS_URL}?format=text&COMMAND='${query.command}'&OBJ_DATA='YES'` +
|
||||
`&MAKE_EPHEM='YES'&EPHEM_TYPE='ELEMENTS'&CENTER='${query.center}'` +
|
||||
`&START_TIME='${REFERENCE_START}'&STOP_TIME='${REFERENCE_STOP}'&STEP_SIZE='1d'`;
|
||||
|
||||
const text = await fetchTextCached(url, query.cacheKey);
|
||||
return {
|
||||
radiusKm: extractRadiusKm(text),
|
||||
orbit: extractOrbitalElements(text)
|
||||
};
|
||||
}
|
||||
|
||||
function extractRadiusKm(text: string): number | undefined {
|
||||
for (const pattern of RADIUS_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractOrbitalElements(text: string): OrbitalElements {
|
||||
const startIndex = text.indexOf('$$SOE');
|
||||
const endIndex = text.indexOf('$$EOE');
|
||||
if (startIndex === -1 || endIndex === -1) {
|
||||
throw new Error('Horizons response did not contain an elements table ($$SOE/$$EOE).');
|
||||
}
|
||||
|
||||
const block = text.slice(startIndex + '$$SOE'.length, endIndex).trim();
|
||||
const firstRecord = block.split(/\n(?=\d)/)[0];
|
||||
|
||||
const epochJd = extractNumber(firstRecord, /^([\d.]+)\s*=/);
|
||||
const eccentricity = extractNumber(firstRecord, /EC\s*=\s*([-\d.Ee+]+)/);
|
||||
const inclinationDeg = extractNumber(firstRecord, /IN\s*=\s*([-\d.Ee+]+)/);
|
||||
const longitudeOfAscendingNodeDeg = extractNumber(firstRecord, /OM\s*=\s*([-\d.Ee+]+)/);
|
||||
const argumentOfPeriapsisDeg = extractNumber(firstRecord, /(?<!\w)W\s*=\s*([-\d.Ee+]+)/);
|
||||
const meanAnomalyAtEpochDeg = extractNumber(firstRecord, /MA\s*=\s*([-\d.Ee+]+)/);
|
||||
const semiMajorAxisKm = extractNumber(firstRecord, /(?<!\w)A\s*=\s*([-\d.Ee+]+)/);
|
||||
|
||||
return {
|
||||
semiMajorAxisAu: semiMajorAxisKm / KM_PER_AU,
|
||||
eccentricity,
|
||||
inclinationDeg,
|
||||
longitudeOfAscendingNodeDeg,
|
||||
argumentOfPeriapsisDeg,
|
||||
meanAnomalyAtEpochDeg,
|
||||
epochJd
|
||||
};
|
||||
}
|
||||
|
||||
function extractNumber(text: string, pattern: RegExp): number {
|
||||
const match = text.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`Could not find pattern ${pattern} in Horizons elements record.`);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), 'tools', 'etl', '.cache');
|
||||
const FORCE_REFRESH = process.env['ETL_FORCE_REFRESH'] === '1';
|
||||
|
||||
/**
|
||||
* Downloads `url` as text, caching the raw response under `tools/etl/.cache/<cacheKey>` so
|
||||
* re-running the ETL doesn't hit live NASA/astronomy endpoints unless the cache is missing
|
||||
* or `ETL_FORCE_REFRESH=1` is set. Keeps the pipeline idempotent and resilient to rate limits.
|
||||
*/
|
||||
export async function fetchTextCached(url: string, cacheKey: string): Promise<string> {
|
||||
const cachePath = join(CACHE_DIR, cacheKey);
|
||||
|
||||
if (!FORCE_REFRESH && existsSync(cachePath)) {
|
||||
return readFileSync(cachePath, 'utf-8');
|
||||
}
|
||||
|
||||
console.log(` fetching ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, text, 'utf-8');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Convenience wrapper around {@link fetchTextCached} that parses the cached response as JSON. */
|
||||
export async function fetchJsonCached<T>(url: string, cacheKey: string): Promise<T> {
|
||||
return JSON.parse(await fetchTextCached(url, cacheKey)) as T;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const DATA_DIR = join(process.cwd(), 'src', 'assets', 'data');
|
||||
|
||||
export function ensureDataDir(): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function dataPath(fileName: string): string {
|
||||
return join(DATA_DIR, fileName);
|
||||
}
|
||||
Reference in New Issue
Block a user