import { toNorthIndianChart, type ReportChartBlock, } from "./report-chart-block.ts"; import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, CHART_VIEW_COPY, COORDINATE_BOUNDARY, DASHA_DUAL_TRACK_NOTE, PLANET_ORDER, SIGN_ORDER, VARGA_CHIP_ORDER, VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS, WESTERN_UNAVAILABLE_COPY, ASPECT_ZH, ELEMENT_ZH, MODE_ZH, WESTERN_BODY_ZH, isSignName, planetZh, signZh, } from "./chart-view-labels.ts"; import { traditionalSymbol } from "./chart-view-symbols.ts"; import { vargaDignity } from "./chart-view-varga.ts"; import { transitsFromEngine } from "./ephemeris-view.ts"; import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts"; import { chartViewOkSchema, type ChartViewBhavaOk, type ChartViewMoonChart, type ChartViewOk, type ChartViewTransitOk, type ChartViewUnavailableLayer, type ChartViewVarga, type ChartViewVargaAscendantRow, type ChartViewVargaRow, type ChartViewWesternOk, type NorthIndianChart, } from "./chart-view-contract.ts"; export type ChartViewProfileInput = { name?: string; date: string; time: string; placeLabel?: string; latitude: number; longitude: number; timezoneOffset: number; timezoneId?: string; ayanamsa?: string; birthTimeStatus?: string; }; export type ChartViewEngineBundle = { chart: Record; varga: Record | null; chara: Record | null; western: Record | null; bhava?: Record | null; transit?: Record | null; profile: ChartViewProfileInput; asOf: string; }; function record(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function finite(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function isoDay(value: string): string { return value.slice(0, 10); } function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } { const start = Date.parse(`${startIso}T00:00:00Z`); const end = Date.parse(`${endIso}T00:00:00Z`); const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); // A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not // replace 0, so total is 0 and `before / total` is NaN. Never call // `Date#toISOString` on that — it throws RangeError and fails the whole chart. if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) { return { start: startIso, end: startIso }; } const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); const weight = Number.isFinite(weights[index]) ? weights[index]! : 0; const span = end - start; const from = start + span * (before / total); const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total); if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso }; return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) }; } function addYears(startIso: string, years: number): string { const start = Date.parse(`${startIso}T00:00:00Z`); return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10); } function containsDay(start: string, end: string, asOf: string): boolean { return start <= asOf && asOf < end; } function asPlanetMap(value: unknown): Record> { const raw = record(value); if (!raw) return {}; const out: Record> = {}; for (const [name, planet] of Object.entries(raw)) { const row = record(planet); if (row) out[name] = row; } return out; } function planetLon(planet: Record): number | null { return finite(planet.lon) ?? finite(planet.longitude) ?? finite(planet.degree_raw); } function planetDegreeInSign(planet: Record): number { return finite(planet.degree_in_sign) ?? finite(planet.degree) ?? ((planetLon(planet) ?? 0) % 30); } function statusLabel(planet: Record): string { const retrograde = Boolean(planet.retrograde); const dignity = text(planet.status) ?? "中性"; return retrograde && !dignity.includes("逆") ? `${dignity} · 逆行` : dignity; } function birthTimeStatusLabel(status: string): string { if (status === "confirmed") return "已校正"; if (status === "accepted") return "已采用"; return "填报时间"; } function nodeModeLabel(mode: "mean" | "true"): string { return mode === "true" ? "真交点" : "平交点"; } function formatCoord(latitude: number, longitude: number): string { const ns = latitude >= 0 ? "N" : "S"; const ew = longitude >= 0 ? "E" : "W"; return `${Math.abs(latitude).toFixed(2)}°${ns} ${Math.abs(longitude).toFixed(2)}°${ew}`; } function houseFromPlanet(planet: Record, ascSign: string): number { const direct = finite(planet.house); if (direct !== null && direct >= 1 && direct <= 12) return Math.trunc(direct); const sign = text(planet.sign); if (!sign || !isSignName(sign) || !isSignName(ascSign)) return 1; return ((SIGN_ORDER.indexOf(sign) - SIGN_ORDER.indexOf(ascSign) + 12) % 12) + 1; } function natalBlock(chart: Record): ReportChartBlock | null { const planets = asPlanetMap(chart.planets); const ascendant = record(chart.ascendant); const ascSign = text(ascendant?.sign); if (!ascSign || !isSignName(ascSign)) return null; const rows: ReportChartBlock["planets"] = []; for (const name of PLANET_ORDER) { const planet = planets[name]; if (!planet) continue; const sign = text(planet.sign); if (!sign || !isSignName(sign)) continue; rows.push({ name, sign, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))), retrograde: Boolean(planet.retrograde), }); } if (rows.length === 0) return null; return { version: 1, id: "D1", title: "D1 — 本命盘", layout: "north", ascendant: { sign: ascSign, degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree_in_sign) ?? finite(ascendant?.degree) ?? 0)), }, planets: rows, }; } function vargaKey(division: number, result: Record): Record | null { const exact = Object.entries(result).find(([key]) => key === `D${division}` || key.startsWith(`D${division}_`)); return exact ? record(exact[1]) : null; } type NatalSigns = { planets: ReadonlyMap; ascendant: string | null }; function houseFromSigns(sign: string, ascSign: string): number { return ((SIGN_ORDER.indexOf(sign as (typeof SIGN_ORDER)[number]) - SIGN_ORDER.indexOf(ascSign as (typeof SIGN_ORDER)[number]) + 12) % 12) + 1; } /** * The divisional chart's own rows: the engine's divisional sign, in-varga * degree and house (counted from the divisional ascendant), plus the D1 sign * each body came from. Nothing is recomputed here except the sign-dignity * lookup, whose tables are the engine's (`chart-view-varga.ts`). */ function vargaRows( ascSign: string, varga: Record, natalRetro: ReadonlyMap, natal: NatalSigns, ): { ascendant: ChartViewVargaAscendantRow | undefined; rows: ChartViewVargaRow[] } { const planets = asPlanetMap(varga.planets); const rows: ChartViewVargaRow[] = []; for (const name of PLANET_ORDER) { const planet = planets[name]; const sign = text(planet?.sign); const natalSign = natal.planets.get(name); if (!planet || !sign || !isSignName(sign) || !natalSign) continue; const engineHouse = finite(planet.house); rows.push({ name, label: planetZh(name), sign, signLabel: signZh(sign), degreeInSign: Math.min(29.9999, Math.max(0, planetDegreeInSign(planet))), house: engineHouse !== null && engineHouse >= 1 && engineHouse <= 12 ? Math.trunc(engineHouse) : houseFromSigns(sign, ascSign), natalSign, natalSignLabel: signZh(natalSign), dignity: vargaDignity(name, sign), vargottama: sign === natalSign, retrograde: natalRetro.get(name) ?? false, }); } const ascendant = record(varga.ascendant); const ascendantRow = natal.ascendant ? { sign: ascSign, signLabel: signZh(ascSign), degreeInSign: Math.min(29.9999, Math.max(0, finite(ascendant?.degree) ?? 0)), natalSign: natal.ascendant, natalSignLabel: signZh(natal.ascendant), vargottama: ascSign === natal.ascendant, } : undefined; return { ascendant: ascendantRow, rows }; } function vargaToChart( id: string, title: string, varga: Record, natalRetro: ReadonlyMap, ): { chart: NorthIndianChart; meaning: string; division: number } | null { const ascendant = record(varga.ascendant); const ascSign = text(ascendant?.sign); if (!ascSign || !isSignName(ascSign)) return null; const planets = asPlanetMap(varga.planets); const rows: ReportChartBlock["planets"] = []; for (const name of PLANET_ORDER) { const planet = planets[name]; if (!planet) continue; const sign = text(planet.sign); if (!sign || !isSignName(sign)) continue; rows.push({ name, sign, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))), retrograde: natalRetro.get(name) ?? false, }); } const division = finite(varga.division) ?? Number(id.slice(1)); const meaning = text(varga.meaning) ?? title; const block: ReportChartBlock = { version: 1, id: id as ReportChartBlock["id"], title, layout: "north", ascendant: { sign: ascSign, degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree) ?? 0)), }, planets: rows, }; return { chart: toNorthIndianChart(block), meaning, division }; } function buildVargas( chart: Record, vargaPacket: Record | null, ): ChartViewVarga[] { const natalRetro = new Map(); const natalPlanetSigns = new Map(); for (const [name, planet] of Object.entries(asPlanetMap(chart.planets))) { natalRetro.set(name, Boolean(planet.retrograde)); const sign = text(planet.sign); if (sign && isSignName(sign)) natalPlanetSigns.set(name, sign); } const natalAsc = text(record(chart.ascendant)?.sign); const natal: NatalSigns = { planets: natalPlanetSigns, ascendant: natalAsc && isSignName(natalAsc) ? natalAsc : null }; const result = record(vargaPacket?.result) ?? {}; const vargas: ChartViewVarga[] = []; for (const division of VARGA_CHIP_ORDER) { const raw = vargaKey(division, result); const id = `D${division}`; if (raw) { const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro); if (mapped) { const ascSign = text(record(raw.ascendant)?.sign)!; vargas.push({ id, title: id, meaning: mapped.meaning, division: mapped.division, chart: mapped.chart, ...(division === 1 ? {} : vargaRows(ascSign, raw, natalRetro, natal)), }); continue; } } if (division === 1) { const block = natalBlock(chart); if (block) { vargas.push({ id: "D1", title: "D1", meaning: "本命盘", division: 1, chart: toNorthIndianChart(block), }); } } } return vargas; } function d9SignFor(name: string, vargaPacket: Record | null): string | null { const result = record(vargaPacket?.result) ?? {}; const d9 = vargaKey(9, result); const planet = asPlanetMap(d9?.planets)[name]; return text(planet?.sign); } function planetRows(chart: Record, vargaPacket: Record | null) { const planets = asPlanetMap(chart.planets); const ascSign = text(record(chart.ascendant)?.sign) ?? "Aries"; return PLANET_ORDER.flatMap((name) => { const planet = planets[name]; if (!planet) return []; const sign = text(planet.sign) ?? "Aries"; const d9 = d9SignFor(name, vargaPacket); return [{ name, label: planetZh(name), sign, signLabel: signZh(sign), degreeInSign: planetDegreeInSign(planet), house: houseFromPlanet(planet, ascSign), retrograde: Boolean(planet.retrograde), status: statusLabel(planet), nakshatra: text(planet.nakshatra) ?? "—", pada: Math.min(4, Math.max(1, Math.trunc(finite(planet.nakshatra_pada) ?? 1))), nakshatraLord: planetZh(text(planet.nakshatra_lord) ?? "—"), d9Sign: d9, d9SignLabel: d9 ? signZh(d9) : null, symbol: traditionalSymbol(name, sign), }]; }); } /** * The ascendant as a planet-table row. Sign and degree come straight from the * engine's `ascendant`; nakshatra, pada and their lord stay null because the * engine does not return them for a chart point and they must not be guessed. */ function ascendantRow(chart: Record) { const ascendant = record(chart.ascendant); const sign = text(ascendant?.sign); if (!ascendant || !sign) return null; const nakshatra = text(ascendant.nakshatra); const pada = finite(ascendant.nakshatra_pada); const lord = text(ascendant.nakshatra_lord); return { label: "上升", abbr: "As", sign, signLabel: signZh(sign), degreeInSign: planetDegreeInSign(ascendant), house: 1 as const, retrograde: false as const, nakshatra, pada: pada === null ? null : Math.min(4, Math.max(1, Math.trunc(pada))), nakshatraLord: lord ? planetZh(lord) : null, }; } /** * The Moon chart: the natal block with the Moon's sign as house 1. Every sign, * degree and direction is the D1's; only the house count changes. */ function moonChart(chart: Record, vargaPacket: Record | null): ChartViewMoonChart | null { const block = natalBlock(chart); const moon = asPlanetMap(chart.planets).Moon; const moonSign = text(moon?.sign); const natalAsc = text(record(chart.ascendant)?.sign); if (!block || !moon || !moonSign || !isSignName(moonSign) || !natalAsc || !isSignName(natalAsc)) return null; const moonBlock: ReportChartBlock = { ...block, id: "MOON", title: "月亮盘", ascendant: { sign: moonSign, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(moon))) }, }; return { moonSign, moonSignLabel: signZh(moonSign), ascendantHouse: houseFromSigns(natalAsc, moonSign), chart: toNorthIndianChart(moonBlock), planets: planetRows(chart, vargaPacket).map((row) => ({ ...row, house: houseFromSigns(row.sign, moonSign) })), }; } function unavailableTransit(reason: string): ChartViewUnavailableLayer { return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable }; } /** * Today's positions placed in the natal houses. The rows are the ephemeris * page's own projection (`transitsFromEngine`), so the two pages agree. */ function mapTransit( raw: Record | null | undefined, chart: Record, profile: ChartViewProfileInput, asOf: string, ): ChartViewTransitOk | ChartViewUnavailableLayer { if (!raw) return unavailableTransit("not_requested"); const natalAsc = text(record(chart.ascendant)?.sign); if (!natalAsc || !isSignName(natalAsc)) return unavailableTransit("natal_ascendant_missing"); const { bodies } = transitsFromEngine({ transitChart: raw, natalChart: chart }); const natalPlanets = asPlanetMap(chart.planets); const rows = bodies.flatMap((body) => { if (body.natalHouse === null || !isSignName(body.sign)) return []; const natal = natalPlanets[body.planet]; return [{ name: body.planet, label: planetZh(body.planet), sign: body.sign, signLabel: signZh(body.sign), degreeInSign: body.degreeInSign, retrograde: body.retrograde, natalHouse: body.natalHouse, natalPlanetHouse: natal ? houseFromPlanet(natal, natalAsc) : null, }]; }); if (rows.length === 0) return unavailableTransit("payload_incomplete"); const block: ReportChartBlock = { version: 1, id: "D1", title: "行运", layout: "north", ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) }, planets: rows.map((row) => ({ name: row.name as ReportChartBlock["planets"][number]["name"], sign: row.sign, degree: Math.min(29.999, Math.max(0, row.degreeInSign)), retrograde: row.retrograde, })), }; return { status: "ok", date: isoDay(asOf), time: TRANSIT_LOCAL_TIME, timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`, chart: toNorthIndianChart(block), rows, }; } function unavailableBhava(reason: string): ChartViewUnavailableLayer { return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable }; } function signAt(lon: number): { sign: (typeof SIGN_ORDER)[number]; degree: number } { const normalized = ((lon % 360) + 360) % 360; return { sign: SIGN_ORDER[Math.floor(normalized / 30) % 12]!, degree: normalized % 30 }; } /** * Sripati Bhava Chalit from `/api/bhava_chalit` (mode `compare`). Refuses to * draw anything the engine did not compute as Sripati from a real MC: a * fallback, a missing boundary table, or the old `asc + 270` placeholder MC * (BUG-1077) all come back unavailable instead of an equal-house chart. */ function mapBhava(raw: Record | null | undefined, chart: Record): ChartViewBhavaOk | ChartViewUnavailableLayer { if (!raw) return unavailableBhava("not_requested"); const result = record(raw.result); if (!result) return unavailableBhava("payload_incomplete"); if (text(raw.selected_house_system) !== "sripati" || text(raw.fallback_reason)) return unavailableBhava("house_system_fallback"); const asc = finite(result.ascendant_lon); const mc = finite(result.mc_lon); if (asc === null || mc === null) return unavailableBhava("mc_missing"); const placeholder = (asc + 270) % 360; if (Math.abs(mc - placeholder) < 1e-6) return unavailableBhava("mc_placeholder"); const natalAsc = text(record(chart.ascendant)?.sign); if (!natalAsc || !isSignName(natalAsc)) return unavailableBhava("natal_ascendant_missing"); const rashi = asPlanetMap(result.rashi_chart); const bhavaHouses = record(result.bhava_chart) ?? {}; const rows: ChartViewBhavaOk["rows"] = []; for (const name of PLANET_ORDER) { const planet = rashi[name]; const sign = text(planet?.sign); const rashiHouse = finite(planet?.house); const bhavaHouse = finite(bhavaHouses[name]); if (!planet || !sign || !isSignName(sign) || rashiHouse === null || bhavaHouse === null) continue; rows.push({ name, label: planetZh(name), sign, signLabel: signZh(sign), degreeInSign: Math.min(29.9999, Math.max(0, finite(planet.degree_in_sign) ?? 0)), rashiHouse: Math.trunc(rashiHouse), bhavaHouse: Math.trunc(bhavaHouse), shifted: Math.trunc(bhavaHouse) !== Math.trunc(rashiHouse), }); } const houses = Array.isArray(record(result.boundaries)?.houses) ? record(result.boundaries)!.houses as unknown[] : []; const cusps: ChartViewBhavaOk["cusps"] = houses.flatMap((entry) => { const house = record(entry); const number = finite(house?.house); const cusp = finite(house?.cusp_lon); const sandhi = finite(house?.sandhi_start_lon); if (!house || number === null || cusp === null || sandhi === null) return []; const at = signAt(cusp); const start = signAt(sandhi); return [{ house: Math.trunc(number), sign: at.sign, signLabel: signZh(at.sign), degreeInSign: at.degree, sandhiSign: start.sign, sandhiSignLabel: signZh(start.sign), sandhiDegreeInSign: start.degree, }]; }); if (rows.length === 0 || cusps.length !== 12) return unavailableBhava("payload_incomplete"); // Draw each body in its Bhava house: the North Indian block places a body by // sign, so hand it the sign that occupies that house from the natal lagna. const ascIndex = SIGN_ORDER.indexOf(natalAsc); const block: ReportChartBlock = { version: 1, id: "D1", title: "Bhava", layout: "north", ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) }, planets: rows.map((row) => ({ name: row.name as ReportChartBlock["planets"][number]["name"], sign: SIGN_ORDER[(ascIndex + row.bhavaHouse - 1) % 12]!, degree: Math.min(29.999, row.degreeInSign), retrograde: Boolean(asPlanetMap(chart.planets)[row.name]?.retrograde), })), }; return { status: "ok", houseSystem: "Sripati", chart: toNorthIndianChart(block), rows, cusps }; } function vimshottariTrack(chart: Record, asOf: string) { const dasha = record(chart.dasha) ?? {}; const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : []; const periods = rawPeriods.flatMap((entry) => { const row = record(entry); if (!row) return []; const lord = text(row.lord); const start = text(row.start); const end = text(row.end); if (!lord || !start || !end) return []; const startIso = isoDay(start); const endIso = isoDay(end); const startIndex = VIMSHOTTARI_ORDER.indexOf(lord as (typeof VIMSHOTTARI_ORDER)[number]); const order = startIndex >= 0 ? VIMSHOTTARI_ORDER.map((_, index) => VIMSHOTTARI_ORDER[(startIndex + index) % VIMSHOTTARI_ORDER.length]!) : [...VIMSHOTTARI_ORDER]; const weights = order.map((name) => VIMSHOTTARI_YEARS[name]); const antardashas = order.map((subLord, index) => { const span = addWeightedSpan(startIso, endIso, index, weights); return { lord: subLord, label: planetZh(subLord), start: span.start, end: span.end, current: containsDay(span.start, span.end, asOf), derived: true, }; }); return [{ lord, label: planetZh(lord), sign: null, start: startIso, end: endIso, current: containsDay(startIso, endIso, asOf), derived: false, antardashas, }]; }); const current = periods.find((item) => item.current); const currentAntar = current?.antardashas.find((item) => item.current); const currentLabel = current ? `${planetZh(current.lord)}大运 / ${currentAntar ? planetZh(currentAntar.lord) : "—"}分运` : "未落入已返回的时段"; return { id: "vimshottari" as const, title: "Vimshottari", method: "Vimshottari", currentLabel, antardashaNote: ANTARDASHA_DERIVED_NOTE, periods, }; } function charaTrack(charaPacket: Record | null, birthDate: string, asOf: string) { const result = record(charaPacket?.result) ?? charaPacket; const chara = record(result?.chara_dasha) ?? result; const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : []; let cursor = birthDate; const periods = sequence.flatMap((entry) => { const row = record(entry); if (!row) return []; const sign = text(row.sign); const lord = text(row.lord) ?? sign ?? "—"; const rawDuration = finite(row.duration_years); const duration = rawDuration === null ? 1 : rawDuration; const zeroYear = duration === 0; const start = cursor; const end = addYears(start, duration); cursor = end; const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : []; const antardashas = zeroYear ? [] : rawAntar.length > 0 ? rawAntar.flatMap((antar, index) => { const item = record(antar); if (!item) return []; const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1); const span = addWeightedSpan(start, end, index, weights); const antarLord = text(item.lord) ?? text(item.sign) ?? "—"; return [{ lord: antarLord, label: `${signZh(text(item.sign) ?? antarLord)} / ${planetZh(antarLord)}`, start: span.start, end: span.end, current: containsDay(span.start, span.end, asOf), derived: true, }]; }) : SIGN_ORDER.map((antarSign, index) => { const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1)); return { lord: antarSign, label: signZh(antarSign), start: span.start, end: span.end, current: containsDay(span.start, span.end, asOf), derived: true, }; }); return [{ lord, label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`, sign: sign, start, end, current: containsDay(start, end, asOf), derived: false, ...(zeroYear ? { zeroYear: true as const } : {}), antardashas, }]; }); const current = periods.find((item) => item.current); const currentAntar = current?.antardashas.find((item) => item.current); const currentLabel = current ? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}` : "未落入已返回的时段"; return { id: "chara" as const, title: "Chara Dasha(kn_rao 变体)", method: text(chara?.method) === "Chara Dasha (kn_rao)" ? "Chara Dasha(kn_rao 变体)" : (text(chara?.method) ?? "Chara Dasha(kn_rao 变体)"), currentLabel, antardashaNote: CHARA_ANTARDASHA_DATE_NOTE, periods, }; } function unavailableWestern(reason: string): ChartViewUnavailableLayer { return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY }; } function mapWestern(raw: Record | null, ayanamsaDisplay: string): ChartViewWesternOk | ChartViewUnavailableLayer { if (!raw) return unavailableWestern("endpoint_missing"); const natal = record(raw.natal) ?? raw; const ascendant = record(natal.ascendant); const planetsRaw = record(natal.planets); const housesRaw = Array.isArray(natal.houses) ? natal.houses : []; if (!ascendant || !planetsRaw || housesRaw.length !== 12) return unavailableWestern("payload_incomplete"); const ascLon = finite(ascendant.longitude); if (ascLon === null) return unavailableWestern("payload_incomplete"); const planets = Object.entries(planetsRaw).flatMap(([id, value]) => { const planet = record(value); if (!planet) return []; const longitude = finite(planet.longitude); const sign = text(planet.sign); if (longitude === null || !sign) return []; return [{ id, label: WESTERN_BODY_ZH[id] ?? id, longitude, sign, signLabel: signZh(sign), degreeInSign: finite(planet.degree_in_sign) ?? (longitude % 30), house: finite(planet.house) !== null ? Math.trunc(finite(planet.house)!) : null, retrograde: Boolean(planet.retrograde), }]; }); const houses = housesRaw.flatMap((entry, index) => { const row = record(entry); const cusp = record(row?.cusp) ?? row; if (!cusp) return []; const longitude = finite(cusp.longitude); const sign = text(cusp.sign); if (longitude === null || !sign) return []; return [{ house: Math.trunc(finite(row?.house) ?? index + 1), longitude, sign, signLabel: signZh(sign), degreeInSign: finite(cusp.degree_in_sign) ?? (longitude % 30), }]; }); if (planets.length === 0 || houses.length !== 12) return unavailableWestern("payload_incomplete"); const aspectsRaw = Array.isArray(natal.aspects) ? natal.aspects : []; const aspects = aspectsRaw.flatMap((entry) => { const row = record(entry); if (!row) return []; const left = text(row.left); const right = text(row.right); const aspect = text(row.aspect); if (!left || !right || !aspect) return []; return [{ left: WESTERN_BODY_ZH[left] ?? left, right: WESTERN_BODY_ZH[right] ?? right, aspect, aspectLabel: ASPECT_ZH[aspect] ?? aspect, orb: finite(row.orb) ?? 0, }]; }); const distribution = record(natal.distribution); const elementsRaw = record(distribution?.elements) ?? {}; const modesRaw = record(distribution?.modes) ?? {}; return { status: "ok", zodiac: "tropical", houseSystem: text(raw.house_system) ?? "P", boundary: COORDINATE_BOUNDARY.western, ayanamsaGapNote: `回归黄道与本页印度盘所用${ayanamsaDisplay}岁差不是同一套;差值随日期变,不能把两边度数对过去。`, ascendantLongitude: ascLon, planets, houses, aspects, elements: ["fire", "earth", "air", "water"].map((id) => ({ id, label: ELEMENT_ZH[id] ?? id, count: Math.max(0, Math.trunc(finite(elementsRaw[id]) ?? 0)), })), modes: ["cardinal", "fixed", "mutable"].map((id) => ({ id, label: MODE_ZH[id] ?? id, count: Math.max(0, Math.trunc(finite(modesRaw[id]) ?? 0)), })), }; } export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); const birth = record(chart.birth) ?? {}; const ayanamsa = text(birth.ayanamsa_name) ?? profile.ayanamsa ?? "raman"; const ayanamsaDisplay = text(birth.ayanamsa_display) ?? ayanamsa; const nodeMode = text(birth.node_mode) === "true" ? "true" : "mean"; const vargas = buildVargas(chart, varga); if (vargas.length === 0) throw new Error("vedic_chart_unavailable"); const vimshottari = vimshottariTrack(chart, asOf); const charaMapped = charaTrack(chara, profile.date, asOf); const placeLabel = profile.placeLabel?.trim() || formatCoord(profile.latitude, profile.longitude); const view = { status: "ok" as const, billed: false as const, profile: { name: profile.name?.trim() || "未命名", date: profile.date, time: profile.time.slice(0, 5), placeLabel, latitude: profile.latitude, longitude: profile.longitude, timezoneOffset: profile.timezoneOffset, timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`, ayanamsa, ayanamsaDisplay, nodeMode, nodeModeLabel: nodeModeLabel(nodeMode), birthTimeStatus: profile.birthTimeStatus ?? "reported", birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"), engineName: "Swiss Ephemeris(本站)", moonNakshatra: text(moon.nakshatra) ?? "—", moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))), ascendantSign: text(ascendant?.sign) ?? "—", ascendantSignLabel: signZh(text(ascendant?.sign) ?? "—"), currentDashaLabel: vimshottari.currentLabel, }, vedic: { coordinateSystem: "sidereal" as const, boundary: COORDINATE_BOUNDARY.vedic, vargas, ascendant: ascendantRow(chart), planets: planetRows(chart, varga), moon: moonChart(chart, varga), }, dasha: { asOf: isoDay(asOf), vimshottari, chara: charaMapped, dualTrackNote: DASHA_DUAL_TRACK_NOTE, }, western: mapWestern(western, ayanamsaDisplay), bhava: mapBhava(bhava, chart), transit: mapTransit(transit, chart, profile, asOf), }; return chartViewOkSchema.parse(view); } export function engineChartHasPlanets(chart: Record): boolean { return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name])); }