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.
This commit is contained in:
jesse-ux
2026-09-25 03:01:48 +08:00
parent c2030a4f36
commit 5a2d9da985
17 changed files with 1305 additions and 104 deletions
@@ -2,46 +2,135 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
type DashaTrack = ChartViewOk["dasha"]["vimshottari"];
type DashaPeriod = DashaTrack["periods"][number];
function formatYearMonth(iso: string): string {
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
}
function dayMs(iso: string): number {
return Date.parse(`${iso.slice(0, 10)}T00:00:00Z`);
}
/** Whole months from `asOf` until `endIso`. The end day is exclusive in the mapper. */
export function dashaRemainingMonths(endIso: string, asOf: string): number {
const [year, month, day] = asOf.slice(0, 10).split("-").map(Number);
const [endYear, endMonth, endDay] = endIso.slice(0, 10).split("-").map(Number);
if (!year || !month || !day || !endYear || !endMonth || !endDay) return 0;
let months = (endYear - year) * 12 + (endMonth - month);
if (endDay < day) months -= 1;
return Math.max(0, months);
}
function NowCard({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) {
const current = periods.find((period) => period.current && !period.zeroYear);
const antar = current?.antardashas.find((item) => item.current);
const remaining = antar ? dashaRemainingMonths(antar.end, asOf) : null;
return (
<section className="chart-page-dasha-now" aria-label="现在">
<h4>现在</h4>
<dl>
<div>
<dt>大运</dt>
<dd>{current ? current.label : "未落入已返回的时段"}</dd>
</div>
<div>
<dt>小运</dt>
<dd>{antar ? antar.label : "—"}</dd>
</div>
<div>
<dt>小运剩余</dt>
<dd>{remaining === null ? "—" : `${remaining} 个月`}</dd>
</div>
</dl>
</section>
);
}
function Timeline({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) {
const visible = periods.filter((period) => !period.zeroYear);
if (visible.length === 0) return null;
const start = visible[0]!.start;
const end = visible[visible.length - 1]!.end;
const startMs = dayMs(start);
const endMs = dayMs(end);
const span = endMs - startMs;
const cursor = span > 0 ? Math.min(1, Math.max(0, (dayMs(asOf) - startMs) / span)) : 0;
return (
<div className="chart-page-dasha-axis">
<div className="chart-page-dasha-bar-wrap">
<div className="chart-page-dasha-bar" role="img" aria-label={`${formatYearMonth(start)} 到 ${formatYearMonth(end)}`}>
{visible.map((period) => {
const weight = span > 0 ? (dayMs(period.end) - dayMs(period.start)) / span : 1 / visible.length;
return (
<span
key={`${period.lord}-${period.start}`}
className={period.current ? "chart-page-dasha-segment is-current" : "chart-page-dasha-segment"}
data-dasha-segment=""
style={{ flexGrow: Math.max(1, Math.round(weight * 1000)) }}
/>
);
})}
</div>
<span className="chart-page-dasha-today" style={{ left: `${cursor * 100}%` }} />
</div>
<div className="chart-page-dasha-scale">
<span>{formatYearMonth(start)}</span>
<span>{formatYearMonth(end)}</span>
</div>
</div>
);
}
function Track({
title,
method,
currentLabel,
antardashaNote,
periods,
}: ChartViewOk["dasha"]["vimshottari"]) {
asOf,
}: DashaTrack & { asOf: string }) {
return (
<section className="chart-page-dasha-track">
<header>
<h3>{title}</h3>
{/* Both tracks ship a `method` equal to their own title, which rendered
the name twice; only show it when it actually adds something. */}
{method && method !== title ? <p>{method}</p> : null}
{/* No space, and no second 当前: the fallback labels used to start with
it themselves, which read as 「当前 当前 Chara 段…」. */}
<p>当前{currentLabel}</p>
</header>
<NowCard periods={periods} asOf={asOf} />
<Timeline periods={periods} asOf={asOf} />
<ol className="chart-page-dasha-list">
{periods.map((period) => (
<li
key={`${period.lord}-${period.start}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<p>
<strong>{period.label}</strong>
<span>{period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`}</span>
</p>
{period.zeroYear ? (
{periods.map((period, index) => (
period.zeroYear ? (
<li key={`${period.lord}-${period.start}-${index}`} className="chart-page-dasha-period is-zero">
<p>
<strong>{period.label}</strong>
<span>{CHARA_ZERO_YEAR_LABEL}</span>
</p>
<p className="chart-page-note">{CHARA_ZERO_YEAR_NOTE}</p>
) : period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "is-current" : undefined}>
{antar.label} · {antar.start} – {antar.end}
</li>
))}
</ul>
) : null}
</li>
</li>
) : (
<li
key={`${period.lord}-${period.start}-${index}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<details open={period.current || undefined}>
<summary>
<span className="chart-page-dasha-summary-label">{period.label}</span>
<span className="chart-page-dasha-dates">{formatYearMonth(period.start)} – {formatYearMonth(period.end)}</span>
</summary>
{period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "chart-page-dasha-antar is-current" : "chart-page-dasha-antar"}>
<span>{antar.label} ·</span>
<span className="chart-page-dasha-dates">{formatYearMonth(antar.start)} – {formatYearMonth(antar.end)}</span>
</li>
))}
</ul>
) : null}
</details>
</li>
)
))}
</ol>
{antardashaNote ? <p className="chart-page-note">{antardashaNote}</p> : null}
@@ -58,16 +147,17 @@ export function ChartDashaTab({
charaPending?: boolean;
charaFailure?: ChartViewFailureStatus;
}) {
const asOf = view.dasha.asOf;
return (
<div className="chart-page-dasha">
<div className="chart-page-dasha-columns">
<Track {...view.dasha.vimshottari} />
<Track {...view.dasha.vimshottari} asOf={asOf} />
{charaPending || charaFailure ? (
<section className="chart-page-dasha-track">
<p className="chart-page-note">{charaPending ? CHART_VIEW_COPY.waitingLayer : chartLayerFailureCopy(charaFailure!)}</p>
</section>
) : (
<Track {...view.dasha.chara} />
<Track {...view.dasha.chara} asOf={asOf} />
)}
</div>
<p className="chart-page-boundary">{view.dasha.dualTrackNote}</p>
@@ -1,9 +1,57 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY } from "@/lib/chart-view-labels";
"use client";
import { useState } from "react";
import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import { formatDegreeMinutes } from "./chart-format";
import { WesternWheelSvg } from "./western-wheel-svg";
export function ChartWesternTab({ view }: { view: ChartViewOk }) {
const STELLIUM_COUNT = 3;
type WesternAspect = ChartViewWesternOk["aspects"][number];
export function westernAspectsForLabel(aspects: readonly WesternAspect[], label: string | null): WesternAspect[] {
if (!label) return [...aspects];
return aspects.filter((aspect) => aspect.left === label || aspect.right === label);
}
function signAt(longitude: number): string {
const index = Math.floor((((longitude % 360) + 360) % 360) / 30) % 12;
return signZh(SIGN_ORDER[index] ?? "Aries");
}
function degreeInSign(longitude: number): number {
return ((longitude % 360) + 360) % 360 % 30;
}
function dominantElementLabels(elements: ChartViewWesternOk["elements"]): string[] {
const max = elements.reduce((best, item) => Math.max(best, item.count), 0);
if (max <= 0) return [];
return elements.filter((item) => item.count === max).map((item) => item.label);
}
function stelliumLabels(planets: ChartViewWesternOk["planets"]): string[] {
const counts = new Map<string, number>();
for (const planet of planets) {
counts.set(planet.signLabel, (counts.get(planet.signLabel) ?? 0) + 1);
}
return [...counts.entries()].filter(([, count]) => count >= STELLIUM_COUNT).map(([label]) => label);
}
function houseSystemLabel(houseSystem: string): string {
return houseSystem === "P" ? "Placidus" : houseSystem;
}
export function ChartWesternTab({
view,
initialSelectedId = null,
}: {
view: ChartViewOk;
initialSelectedId?: string | null;
}) {
const western = view.western;
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
if (western.status === "unavailable") {
return (
<div className="chart-page-unavailable">
@@ -12,42 +60,107 @@ export function ChartWesternTab({ view }: { view: ChartViewOk }) {
</div>
);
}
const selected = western.planets.find((planet) => planet.id === selectedId) ?? null;
const aspects = westernAspectsForLabel(western.aspects, selected?.label ?? null);
const elements = dominantElementLabels(western.elements);
const stelliums = stelliumLabels(western.planets);
const sun = western.planets.find((planet) => planet.id === "sun");
const moon = western.planets.find((planet) => planet.id === "moon");
function toggle(id: string) {
setSelectedId((current) => (current === id ? null : id));
}
return (
<div className="chart-page-western">
<div className="chart-page-western-layout">
<WesternWheelSvg western={western} />
<div className="chart-page-western-wheel">
<WesternWheelSvg
western={western}
selectedLabel={selected?.label ?? null}
onSelectPlanet={toggle}
/>
</div>
<div className="chart-page-western-side">
<p className="chart-page-boundary">{western.boundary}</p>
<p className="chart-page-note">{western.ayanamsaGapNote}</p>
<ul className="chart-page-western-chips">
<li>上升 {signAt(western.ascendantLongitude)} {formatDegreeMinutes(degreeInSign(western.ascendantLongitude))}</li>
{sun ? <li>太阳 {sun.signLabel} {formatDegreeMinutes(sun.degreeInSign)}</li> : null}
{moon ? <li>月亮 {moon.signLabel} {formatDegreeMinutes(moon.degreeInSign)}</li> : null}
{elements.length > 0 ? <li>元素最多 {elements.join(" · ")}</li> : null}
{stelliums.length > 0 ? <li>星群 {stelliums.join(" · ")}</li> : null}
</ul>
<section>
<h3>元素 / 模式</h3>
<p>{western.elements.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
<p>{western.modes.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
</section>
<section>
<h3>主要相位</h3>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<h3>行星位置</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体</th>
<th scope="col">星座</th>
<th scope="col">度数</th>
<th scope="col">宫位</th>
<th scope="col">逆行</th>
</tr>
</thead>
<tbody>
{western.planets.map((planet) => {
const selectedRow = selected?.id === planet.id;
return (
<tr
key={planet.id}
data-planet-row=""
className={selectedRow ? "is-selected" : undefined}
tabIndex={0}
aria-pressed={selectedRow}
onClick={() => toggle(planet.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggle(planet.id);
}}
>
<td>{planet.label}</td>
<td>{planet.signLabel}</td>
<td className="chart-page-western-num">{formatDegreeMinutes(planet.degreeInSign)}</td>
<td>{planet.house ?? "—"}</td>
<td>{planet.retrograde ? "逆" : "顺"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
<section>
<h3>主要相位</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体对</th>
<th scope="col">相位</th>
<th scope="col">容许度</th>
</tr>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
{aspects.length === 0 ? (
<tr>
<td colSpan={3}>没有主要相位</td>
</tr>
) : aspects.map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`} data-aspect-row="">
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
<td className="chart-page-western-num">{aspect.orb.toFixed(1)}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p>宫制 {western.houseSystem === "P" ? "Placidus" : western.houseSystem}</p>
<p className="chart-page-boundary">{western.boundary}</p>
<p className="chart-page-note">{western.ayanamsaGapNote}</p>
<p className="chart-page-note">宫制 {houseSystemLabel(western.houseSystem)}</p>
</div>
</div>
</div>
@@ -0,0 +1,260 @@
/** Screen geometry for the tropical wheel. Angles stay in the existing
* orientation: the ascendant is on the left, and longitude increases clockwise.
*/
export const WESTERN_SIZE = 520;
export const WESTERN_CX = 260;
export const WESTERN_CY = 260;
export const WESTERN_R_OUTER = 252;
export const WESTERN_R_ZODIAC = 228;
export const WESTERN_R_PLANET = 188;
export const WESTERN_R_PLANET_INNER = 136;
export const WESTERN_R_HOUSE = 112;
export const WESTERN_R_NUMBER = 84;
export const WESTERN_R_ASPECT = 62;
export const WESTERN_R_ANGLE = 162;
export const WESTERN_FONT_SIZE = 17;
export const WESTERN_MIN_SEPARATION_DEG = 11;
const TEXT_PRESENTATION = "\uFE0E";
const WESTERN_GLYPH: Record<string, string> = {
sun: "☉",
moon: "☽",
mercury: "☿",
venus: `♀${TEXT_PRESENTATION}`,
mars: `♂${TEXT_PRESENTATION}`,
jupiter: "♃",
saturn: "♄",
uranus: "♅",
neptune: "♆",
pluto: "♇",
true_node: "☊",
};
export type WesternLayoutBody = {
id: string;
longitude: number;
caption: string;
};
export type WesternLayoutResult = {
id: string;
longitude: number;
displayLongitude: number;
tier: 0 | 1;
};
export type WesternLabelBox = {
left: number;
top: number;
right: number;
bottom: number;
};
export function normalizeDegrees(value: number): number {
return ((value % 360) + 360) % 360;
}
export function westernScreenAngle(longitude: number, ascendantLongitude: number): number {
return 180 - (longitude - ascendantLongitude);
}
export function westernPolar(radius: number, screenDeg: number): { x: number; y: number } {
const rad = (screenDeg * Math.PI) / 180;
return {
x: WESTERN_CX + radius * Math.cos(rad),
y: WESTERN_CY - radius * Math.sin(rad),
};
}
/** Forward midpoint from one cusp to the next, not the cusp itself. */
export function houseMidLongitude(cusp: number, nextCusp: number): number {
const start = normalizeDegrees(cusp);
const span = (normalizeDegrees(nextCusp) - start + 360) % 360;
return normalizeDegrees(start + span / 2);
}
export function westernPlanetCaption(planet: {
id: string;
label: string;
degreeInSign: number;
retrograde: boolean;
}): string {
const glyph = WESTERN_GLYPH[planet.id] ?? planet.label;
const whole = Math.floor(Math.min(29.9999, Math.max(0, planet.degreeInSign)));
return `${glyph}${whole}°${planet.retrograde ? "℞" : ""}`;
}
export function westernTextWidth(text: string, fontSize = WESTERN_FONT_SIZE): number {
let width = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code === 0xfe0e || code === 0xfe0f) continue;
width += code > 0xff ? fontSize : fontSize * 0.62;
}
return width;
}
export function westernLabelBox(
x: number,
y: number,
text: string,
fontSize = WESTERN_FONT_SIZE,
): WesternLabelBox {
const width = westernTextWidth(text, fontSize);
const height = fontSize;
return {
left: x - width / 2,
top: y - height / 2,
right: x + width / 2,
bottom: y + height / 2,
};
}
export function westernLabelInsideViewBox(box: WesternLabelBox, size = WESTERN_SIZE): boolean {
return box.left >= 0 && box.top >= 0 && box.right <= size && box.bottom <= size;
}
function boxesOverlap(a: WesternLabelBox, b: WesternLabelBox): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function relax(original: number[]): number[] {
const count = original.length;
const pos = original.slice();
const min = WESTERN_MIN_SEPARATION_DEG;
for (let iter = 0; iter < 8; iter += 1) {
for (let index = 1; index < count; index += 1) {
const floor = pos[index - 1]! + min;
if (pos[index]! < floor) pos[index] = floor;
}
const limit = pos[0]! + 360 - min;
if (pos[count - 1]! > limit) {
const overflow = pos[count - 1]! - limit;
for (let index = 0; index < count; index += 1) pos[index] = pos[index]! - overflow;
for (let index = count - 2; index >= 0; index -= 1) {
const ceil = pos[index + 1]! - min;
if (pos[index]! > ceil) pos[index] = ceil;
}
}
for (let index = 0; index < count; index += 1) {
const lo = index === 0 ? pos[count - 1]! - 360 + min : pos[index - 1]! + min;
const hi = index === count - 1 ? pos[0]! + 360 - min : pos[index + 1]! - min;
if (hi < lo) continue;
pos[index] = Math.min(hi, Math.max(lo, original[index]!));
}
}
return pos;
}
function separateSorted(sorted: readonly number[]): number[] {
const count = sorted.length;
if (count === 0) return [];
if (count === 1) return [normalizeDegrees(sorted[0]!)];
let cut = 0;
let largest = -1;
for (let index = 0; index < count; index += 1) {
const gap = (sorted[(index + 1) % count]! - sorted[index]! + 360) % 360;
if (gap > largest) {
largest = gap;
cut = (index + 1) % count;
}
}
const order = Array.from({ length: count }, (_, step) => (cut + step) % count);
const original: number[] = [];
for (let step = 0; step < count; step += 1) {
const longitude = sorted[order[step]!]!;
if (step === 0) original.push(longitude);
else {
let next = longitude;
while (next < original[step - 1]!) next += 360;
original.push(next);
}
}
const placed = relax(original);
const display = sorted.slice();
for (let step = 0; step < count; step += 1) display[order[step]!] = normalizeDegrees(placed[step]!);
return display;
}
function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: 0 | 1) {
const radius = tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude));
}
/**
* Push planet captions apart along the ecliptic until adjacent display angles
* are at least 11°. A second radius is used only when the primary radius would
* still overlap; the font size stays at 17.
*/
export function layoutWesternLabels(
planets: readonly WesternLayoutBody[],
ascendantLongitude = 0,
): WesternLayoutResult[] {
if (planets.length === 0) return [];
const indexed = planets.map((planet, index) => ({
planet,
index,
longitude: normalizeDegrees(planet.longitude),
}));
indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id));
const display = separateSorted(indexed.map((item) => item.longitude));
const byDisplay = indexed
.map((_, index) => index)
.sort((a, b) => display[a]! - display[b]! || indexed[a]!.planet.id.localeCompare(indexed[b]!.planet.id));
const overlapCount = (tiers: readonly (0 | 1)[]) => {
const boxes = indexed.map((item, index) => {
const point = planetPoint(display[index]!, ascendantLongitude, tiers[index]!);
return westernLabelBox(point.x, point.y, item.planet.caption);
});
let count = 0;
for (let left = 0; left < boxes.length; left += 1) {
for (let right = left + 1; right < boxes.length; right += 1) {
if (boxesOverlap(boxes[left]!, boxes[right]!)) count += 1;
}
}
return count;
};
const alternate = (phase: 0 | 1) => {
const tiers = new Array<0 | 1>(indexed.length).fill(0);
byDisplay.forEach((sortedIndex, order) => {
tiers[sortedIndex] = ((order + phase) % 2) as 0 | 1;
});
return tiers;
};
const flat = new Array<0 | 1>(indexed.length).fill(0);
let tiers = flat;
if (overlapCount(flat) > 0) {
const primary = alternate(0);
const secondary = alternate(1);
tiers = overlapCount(secondary) < overlapCount(primary) ? secondary : primary;
}
const results: WesternLayoutResult[] = new Array(planets.length);
indexed.forEach((item, index) => {
results[item.index] = {
id: item.planet.id,
longitude: item.longitude,
displayLongitude: display[index]!,
tier: tiers[index]!,
};
});
return results;
}
export function westernRingSlice(inner: number, outer: number, startDeg: number, endDeg: number): string {
const sweep = ((endDeg - startDeg) % 360 + 360) % 360;
const large = sweep > 180 ? 1 : 0;
const outerStart = westernPolar(outer, startDeg);
const outerEnd = westernPolar(outer, startDeg + sweep);
const innerEnd = westernPolar(inner, startDeg + sweep);
const innerStart = westernPolar(inner, startDeg);
const fmt = (value: number) => value.toFixed(2);
return [
`M ${fmt(outerStart.x)} ${fmt(outerStart.y)}`,
`A ${outer} ${outer} 0 ${large} 0 ${fmt(outerEnd.x)} ${fmt(outerEnd.y)}`,
`L ${fmt(innerEnd.x)} ${fmt(innerEnd.y)}`,
`A ${inner} ${inner} 0 ${large} 1 ${fmt(innerStart.x)} ${fmt(innerStart.y)}`,
"Z",
].join(" ");
}
@@ -1,84 +1,263 @@
import type { ChartViewWesternOk } from "@/lib/chart-view-contract";
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels";
import {
WESTERN_CX,
WESTERN_CY,
WESTERN_R_ANGLE,
WESTERN_R_ASPECT,
WESTERN_R_HOUSE,
WESTERN_R_NUMBER,
WESTERN_R_OUTER,
WESTERN_R_PLANET,
WESTERN_R_PLANET_INNER,
WESTERN_R_ZODIAC,
WESTERN_SIZE,
houseMidLongitude,
layoutWesternLabels,
westernPlanetCaption,
westernPolar,
westernRingSlice,
westernScreenAngle,
} from "./western-wheel-layout";
const SIZE = 400;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 188;
const ZODIAC = 168;
const HOUSE = 128;
const INNER = 78;
const SIGN_ELEMENT: Record<SignName, "fire" | "earth" | "air" | "water"> = {
Aries: "fire",
Taurus: "earth",
Gemini: "air",
Cancer: "water",
Leo: "fire",
Virgo: "earth",
Libra: "air",
Scorpio: "water",
Sagittarius: "fire",
Capricorn: "earth",
Aquarius: "air",
Pisces: "water",
};
function polar(radius: number, deg: number): { x: number; y: number } {
const rad = (deg * Math.PI) / 180;
return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) };
const SOFT_ASPECTS = new Set(["conjunction", "sextile", "trine"]);
const HARD_ASPECTS = new Set(["square", "opposition"]);
type WesternWheelModel = Pick<ChartViewWesternOk, "ascendantLongitude" | "houses" | "planets" | "aspects">;
export const EMPTY_WESTERN_WHEEL: WesternWheelModel = {
ascendantLongitude: 0,
houses: [],
planets: [],
aspects: [],
};
function fmt(value: number): string {
return value.toFixed(2);
}
function screenAngle(longitude: number, ascendant: number): number {
return 180 - (longitude - ascendant);
function pointToward(
from: { x: number; y: number },
to: { x: number; y: number },
stop: number,
): { x: number; y: number } {
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.hypot(dx, dy) || 1;
const travel = Math.max(0, dist - stop);
return { x: from.x + (dx / dist) * travel, y: from.y + (dy / dist) * travel };
}
type WesternWheelModel = Pick<ChartViewWesternOk, "ascendantLongitude" | "houses" | "planets">;
export const EMPTY_WESTERN_WHEEL: WesternWheelModel = { ascendantLongitude: 0, houses: [], planets: [] };
function aspectTone(aspect: string): "soft" | "hard" | "other" {
if (SOFT_ASPECTS.has(aspect)) return "soft";
if (HARD_ASPECTS.has(aspect)) return "hard";
return "other";
}
export function WesternWheelSvg({ western: suppliedWestern, skeleton = false }: { western: WesternWheelModel; skeleton?: boolean }) {
export function WesternWheelSvg({
western: suppliedWestern,
skeleton = false,
selectedLabel = null,
onSelectPlanet,
}: {
western: WesternWheelModel;
skeleton?: boolean;
selectedLabel?: string | null;
onSelectPlanet?: (id: string) => void;
}) {
const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern;
const asc = western.ascendantLongitude;
const signWedges = SIGN_ORDER.map((sign, index) => {
const mid = screenAngle(index * 30 + 15, asc);
return { sign, label: polar((ZODIAC + OUTER) / 2, mid) };
});
const placements = layoutWesternLabels(
western.planets.map((planet) => ({
id: planet.id,
longitude: planet.longitude,
caption: westernPlanetCaption(planet),
})),
asc,
);
const placementById = new Map(placements.map((item) => [item.id, item]));
const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude]));
longitudeByLabel.set("上升", asc);
const tenth = western.houses.find((house) => house.house === 10);
if (tenth) longitudeByLabel.set("中天", tenth.longitude);
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
viewBox={`0 0 ${WESTERN_SIZE} ${WESTERN_SIZE}`}
width="100%"
role="img"
aria-label={skeleton ? undefined : "回归黄道本命盘,上升在左,宫位按 Placidus"}
aria-hidden={skeleton || undefined}
data-skeleton={skeleton ? "true" : undefined}
data-filtering={selectedLabel ? "true" : undefined}
className="chart-page-western-svg"
>
<circle cx={CX} cy={CY} r={OUTER} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={ZODIAC} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={HOUSE} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={INNER} className="chart-page-western-core" />
{signWedges.map((wedge) => {
const start = polar(ZODIAC, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
const outer = polar(OUTER, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
{!skeleton && SIGN_ORDER.map((sign, index) => {
const start = westernScreenAngle(index * 30 + 30, asc);
const end = westernScreenAngle(index * 30, asc);
return (
<g key={wedge.sign}>
<line x1={start.x} y1={start.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
{!skeleton && <text x={wedge.label.x} y={wedge.label.y} textAnchor="middle" dominantBaseline="middle">
{signZh(wedge.sign)}
</text>}
<path
key={sign}
d={westernRingSlice(WESTERN_R_ZODIAC, WESTERN_R_OUTER, start, end)}
className="chart-page-western-sign"
data-element={SIGN_ELEMENT[sign]}
/>
);
})}
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_OUTER} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_ZODIAC} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_HOUSE} className="chart-page-western-ring" />
{SIGN_ORDER.map((sign, index) => {
const edge = westernScreenAngle(index * 30, asc);
const inner = westernPolar(WESTERN_R_ZODIAC, edge);
const outer = westernPolar(WESTERN_R_OUTER, edge);
const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc));
return (
<g key={`${sign}-spoke`}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
{!skeleton && (
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-sign-label">
{signZh(sign)}
</text>
)}
</g>
);
})}
{western.houses.map((house) => {
const angle = screenAngle(house.longitude, asc);
const inner = polar(INNER, angle);
const outer = polar(ZODIAC, angle);
const label = polar((INNER + HOUSE) / 2, angle);
{!skeleton && western.houses.map((house, index) => {
const next = western.houses[(index + 1) % western.houses.length] ?? house;
const angle = westernScreenAngle(house.longitude, asc);
const inner = westernPolar(WESTERN_R_ASPECT, angle);
const outer = westernPolar(WESTERN_R_ZODIAC, angle);
const mid = houseMidLongitude(house.longitude, next.longitude);
const label = westernPolar(WESTERN_R_NUMBER, westernScreenAngle(mid, asc));
const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10;
return (
<g key={house.house}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-house" />
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-house-label">
<line
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
className={axis ? "chart-page-western-house is-axis" : "chart-page-western-house"}
/>
<text
x={label.x}
y={label.y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-house-label"
data-house={house.house}
data-longitude={fmt(mid)}
>
{house.house}
</text>
</g>
);
})}
{western.planets.map((planet) => {
const point = polar((HOUSE + INNER) / 2 + 10, screenAngle(planet.longitude, asc));
{!skeleton && western.aspects.map((aspect) => {
const left = longitudeByLabel.get(aspect.left);
const right = longitudeByLabel.get(aspect.right);
if (left === undefined || right === undefined) return null;
const from = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(left, asc));
const to = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(right, asc));
const active = selectedLabel !== null && (aspect.left === selectedLabel || aspect.right === selectedLabel);
return (
<text key={planet.id} x={point.x} y={point.y} textAnchor="middle" dominantBaseline="middle">
{planet.label}
</text>
<line
key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
className="chart-page-western-aspect"
data-tone={aspectTone(aspect.aspect)}
data-active={active ? "true" : undefined}
/>
);
})}
{!skeleton && <text x={polar(OUTER + 4, 180).x - 8} y={CY} textAnchor="end" dominantBaseline="middle" className="chart-page-western-asc">
升
</text>}
{!skeleton && western.planets.map((planet) => {
const placement = placementById.get(planet.id);
if (!placement) return null;
const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc));
const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc));
const leaderEnd = pointToward(truth, display, 14);
const selected = selectedLabel === planet.label;
const caption = westernPlanetCaption(planet);
return (
<g
key={planet.id}
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
data-tier={placement.tier}
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={planet.label}
onClick={() => onSelectPlanet?.(planet.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelectPlanet?.(planet.id);
}}
>
<line
x1={truth.x}
y1={truth.y}
x2={leaderEnd.x}
y2={leaderEnd.y}
className="chart-page-western-leader"
data-active={selected ? "true" : undefined}
/>
<line
x1={westernPolar(WESTERN_R_ZODIAC, westernScreenAngle(planet.longitude, asc)).x}
y1={westernPolar(WESTERN_R_ZODIAC, westernScreenAngle(planet.longitude, asc)).y}
x2={truth.x}
y2={truth.y}
className="chart-page-western-tick"
/>
<circle cx={display.x} cy={display.y} r={22} className="chart-page-western-hit" />
<text x={display.x} y={display.y} textAnchor="middle" dominantBaseline="middle">
{caption}
</text>
</g>
);
})}
{!skeleton && (
<text
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).x}
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-angle"
>
ASC
</text>
)}
{!skeleton && tenth && (
<text
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).x}
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-angle"
>
MC
</text>
)}
</svg>
);
}