The dasha tab leads with a now card and a duration bar, and keeps sub-periods inside the current details element. A zero-year Chara row stays grey, shows 0 年, and cannot expand. The tropical wheel uses a 520 view box, element bands, separated planet labels, and aspect lines that stay visible while a selected body is highlighted.
714 lines
25 KiB
TypeScript
714 lines
25 KiB
TypeScript
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<string, unknown>;
|
||
varga: Record<string, unknown> | null;
|
||
chara: Record<string, unknown> | null;
|
||
western: Record<string, unknown> | null;
|
||
qizheng: Record<string, unknown> | null;
|
||
profile: ChartViewProfileInput;
|
||
asOf: string;
|
||
};
|
||
|
||
function record(value: unknown): Record<string, unknown> | null {
|
||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||
? value as Record<string, unknown>
|
||
: 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<string, Record<string, unknown>> {
|
||
const raw = record(value);
|
||
if (!raw) return {};
|
||
const out: Record<string, Record<string, unknown>> = {};
|
||
for (const [name, planet] of Object.entries(raw)) {
|
||
const row = record(planet);
|
||
if (row) out[name] = row;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function planetLon(planet: Record<string, unknown>): number | null {
|
||
return finite(planet.lon) ?? finite(planet.longitude) ?? finite(planet.degree_raw);
|
||
}
|
||
|
||
function planetDegreeInSign(planet: Record<string, unknown>): number {
|
||
return finite(planet.degree_in_sign) ?? finite(planet.degree) ?? ((planetLon(planet) ?? 0) % 30);
|
||
}
|
||
|
||
function statusLabel(planet: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>): 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<string, unknown>): Record<string, unknown> | 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<string, unknown>,
|
||
natalRetro: ReadonlyMap<string, boolean>,
|
||
): { 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<string, unknown>,
|
||
vargaPacket: Record<string, unknown> | null,
|
||
): ChartViewVarga[] {
|
||
const natalRetro = new Map<string, boolean>();
|
||
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<string, unknown> | 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<string, unknown>, vargaPacket: Record<string, unknown> | 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<string, unknown>) {
|
||
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<string, unknown>, 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<string, unknown> | 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<string, unknown> | 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, unknown>): string | null {
|
||
return text(row.branch) ?? text(row.zhi) ?? text(row.earthly_branch) ?? text(row.name);
|
||
}
|
||
|
||
function mapQizheng(raw: Record<string, unknown> | 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<string, unknown>): boolean {
|
||
return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name]));
|
||
}
|
||
|
||
|