import { toNorthIndianChart, type ReportChartBlock, } from "./report-chart-block.ts"; import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, COORDINATE_BOUNDARY, DASHA_DUAL_TRACK_NOTE, PLANET_ORDER, QIZHENG_UNAVAILABLE_COPY, 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 { chartViewOkSchema, type ChartViewOk, type ChartViewQizhengOk, type ChartViewUnavailableLayer, type ChartViewVarga, 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; qizheng: 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; } 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(); for (const [name, planet] of Object.entries(asPlanetMap(chart.planets))) { natalRetro.set(name, Boolean(planet.retrograde)); } 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) { vargas.push({ id, title: id, meaning: mapped.meaning, division: mapped.division, chart: mapped.chart, }); 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, }; } 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 unavailableQizheng(reason: string): ChartViewUnavailableLayer { return { status: "unavailable", reason, copy: QIZHENG_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)), })), }; } function palaceBranch(row: Record): string | null { return text(row.branch) ?? text(row.zhi) ?? text(row.earthly_branch) ?? text(row.name); } function mapQizheng(raw: Record | null): ChartViewQizhengOk | ChartViewUnavailableLayer { if (!raw) return unavailableQizheng("endpoint_missing"); const palacesRaw = raw.palaces; const bodiesRaw = raw.bodies ?? raw.planets; const palaces: ChartViewQizhengOk["palaces"] = []; if (Array.isArray(palacesRaw)) { for (const entry of palacesRaw) { const row = record(entry); if (!row) continue; const branch = palaceBranch(row); if (!branch) continue; palaces.push({ branch, lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? text(row.human_palace) ?? "—", mansions: Array.isArray(row.mansions) ? row.mansions.filter((item): item is string => typeof item === "string") : text(row.mansions) ? [String(row.mansions)] : (text(row.xiu) ? [String(row.xiu)] : []), occupants: Array.isArray(row.occupants) ? row.occupants.filter((item): item is string => typeof item === "string") : [], }); } } else { const asRecord = record(palacesRaw); if (asRecord) { for (const [branch, value] of Object.entries(asRecord)) { const row = record(value) ?? {}; palaces.push({ branch, lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? "—", mansions: Array.isArray(row.mansions) ? row.mansions.filter((item): item is string => typeof item === "string") : [], occupants: Array.isArray(row.occupants) ? row.occupants.filter((item): item is string => typeof item === "string") : [], }); } } } const bodies: ChartViewQizhengOk["bodies"] = []; if (Array.isArray(bodiesRaw)) { for (const entry of bodiesRaw) { const row = record(entry); if (!row) continue; const name = text(row.name) ?? text(row.body) ?? text(row.planet); if (!name) continue; bodies.push({ name, mansion: text(row.mansion) ?? text(row.xiu) ?? "—", mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0, palace: text(row.palace) ?? text(row.branch) ?? "—", dignity: text(row.dignity) ?? text(row.status) ?? "未闭合", dignityClosed: false, }); } } else { const asRecord = record(bodiesRaw); if (asRecord) { for (const [name, value] of Object.entries(asRecord)) { const row = record(value) ?? {}; bodies.push({ name, mansion: text(row.mansion) ?? text(row.xiu) ?? "—", mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0, palace: text(row.palace) ?? text(row.branch) ?? "—", dignity: text(row.dignity) ?? "未闭合", dignityClosed: false, }); } } } if (palaces.length === 0 || bodies.length === 0) return unavailableQizheng("payload_incomplete"); return { status: "ok", coordinateSystem: text(raw.coordinate_system) ?? "qizheng_mansion_degrees_from_jiao", ketuMode: text(record(raw.calculation)?.engine_ketu_mode) ?? text(record(raw.chart)?.ketu_mode) ?? text(raw.ketuMode) ?? "unspecified", boundary: COORDINATE_BOUNDARY.qizheng, palaces, bodies, }; } export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { const { chart, varga, chara, western, qizheng, 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), }, dasha: { asOf: isoDay(asOf), vimshottari, chara: charaMapped, dualTrackNote: DASHA_DUAL_TRACK_NOTE, }, western: mapWestern(western, ayanamsaDisplay), qizheng: mapQizheng(qizheng), }; return chartViewOkSchema.parse(view); } export function engineChartHasPlanets(chart: Record): boolean { return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name])); }