Files
Jyotisha/frontend/src/lib/chart-view-mapper.ts
T
jesse-ux 5a2d9da985 feat: fold dasha periods and redraw the western wheel
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.
2026-09-25 03:01:48 +08:00

714 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]));
}