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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user