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.
156 lines
6.3 KiB
TypeScript
156 lines
6.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
WESTERN_CX,
|
|
WESTERN_CY,
|
|
WESTERN_MIN_SEPARATION_DEG,
|
|
WESTERN_R_NUMBER,
|
|
WESTERN_R_PLANET,
|
|
WESTERN_R_PLANET_INNER,
|
|
WESTERN_SIZE,
|
|
layoutWesternLabels,
|
|
westernLabelBox,
|
|
westernLabelInsideViewBox,
|
|
westernPlanetCaption,
|
|
westernPolar,
|
|
westernScreenAngle,
|
|
type WesternLayoutBody,
|
|
} from "../src/components/chart-page/western-wheel-layout.ts";
|
|
import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "../src/components/chart-page/western-wheel-svg.tsx";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const fixture = JSON.parse(
|
|
readFileSync(new URL("./fixtures/western-1990-01-01-beijing.json", import.meta.url), "utf8"),
|
|
) as {
|
|
ascendant: { longitude: number };
|
|
planets: Array<WesternLayoutBody & {
|
|
label: string;
|
|
sign: string;
|
|
signLabel: string;
|
|
degreeInSign: number;
|
|
house: number;
|
|
retrograde: boolean;
|
|
}>;
|
|
houses: Array<{ house: number; longitude: number; sign: string; signLabel: string; degreeInSign: number }>;
|
|
};
|
|
|
|
function normalize(value: number): number {
|
|
return ((value % 360) + 360) % 360;
|
|
}
|
|
|
|
function forwardGap(from: number, to: number): number {
|
|
return (to - from + 360) % 360;
|
|
}
|
|
|
|
function longitudeFromPoint(x: number, y: number, ascendant: number): number {
|
|
const screen = (Math.atan2(WESTERN_CY - y, x - WESTERN_CX) * 180) / Math.PI;
|
|
return normalize(ascendant + 180 - screen);
|
|
}
|
|
|
|
function houseMid(cusp: number, next: number): number {
|
|
const start = normalize(cusp);
|
|
const span = forwardGap(start, normalize(next));
|
|
return normalize(start + span / 2);
|
|
}
|
|
|
|
function boxesOverlap(
|
|
a: { left: number; top: number; right: number; bottom: number },
|
|
b: { left: number; top: number; right: number; bottom: number },
|
|
): boolean {
|
|
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
|
}
|
|
|
|
test("layoutWesternLabels separates the 1990-01-01 Capricorn cluster by at least 11 degrees", () => {
|
|
const bodies: WesternLayoutBody[] = fixture.planets.map((planet) => ({
|
|
id: planet.id,
|
|
longitude: planet.longitude,
|
|
caption: westernPlanetCaption(planet),
|
|
}));
|
|
const placed = layoutWesternLabels(bodies, fixture.ascendant.longitude);
|
|
assert.equal(placed.length, bodies.length);
|
|
const sun = fixture.planets.find((planet) => planet.id === "sun");
|
|
const neptune = fixture.planets.find((planet) => planet.id === "neptune");
|
|
assert.ok(sun && neptune);
|
|
assert.ok(forwardGap(sun.longitude, neptune.longitude) < WESTERN_MIN_SEPARATION_DEG);
|
|
const ordered = [...placed].sort((a, b) => a.displayLongitude - b.displayLongitude);
|
|
for (let index = 0; index < ordered.length; index += 1) {
|
|
const gap = forwardGap(ordered[index]!.displayLongitude, ordered[(index + 1) % ordered.length]!.displayLongitude);
|
|
assert.ok(gap >= WESTERN_MIN_SEPARATION_DEG - 1e-6, `${ordered[index]!.id} gap ${gap}`);
|
|
}
|
|
const boxes = placed.map((item) => {
|
|
const body = bodies.find((planet) => planet.id === item.id)!;
|
|
const radius = item.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
|
|
const point = westernPolar(radius, westernScreenAngle(item.displayLongitude, fixture.ascendant.longitude));
|
|
const box = westernLabelBox(point.x, point.y, body.caption);
|
|
assert.equal(westernLabelInsideViewBox(box), true, item.id);
|
|
return box;
|
|
});
|
|
for (let left = 0; left < boxes.length; left += 1) {
|
|
for (let right = left + 1; right < boxes.length; right += 1) {
|
|
assert.equal(boxesOverlap(boxes[left]!, boxes[right]!), false, `${placed[left]!.id} overlaps ${placed[right]!.id}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("house numbers sit at the house midpoint and every label stays inside the viewBox", () => {
|
|
const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, {
|
|
western: {
|
|
ascendantLongitude: fixture.ascendant.longitude,
|
|
planets: fixture.planets,
|
|
houses: fixture.houses,
|
|
aspects: [],
|
|
},
|
|
}));
|
|
assert.match(markup, /viewBox="0 0 520 520"/);
|
|
assert.doesNotMatch(markup, /<text[^>]*>升<\/text>/);
|
|
assert.match(markup, />ASC</);
|
|
assert.match(markup, />MC</);
|
|
const ascendant = fixture.ascendant.longitude;
|
|
for (let index = 0; index < fixture.houses.length; index += 1) {
|
|
const house = fixture.houses[index]!;
|
|
const next = fixture.houses[(index + 1) % fixture.houses.length]!;
|
|
const expected = houseMid(house.longitude, next.longitude);
|
|
const tag = markup.match(new RegExp(`<text x="([^"]+)" y="([^"]+)"[^>]*data-house="${house.house}"`));
|
|
assert.ok(tag, `missing house ${house.house}`);
|
|
const longitude = longitudeFromPoint(Number(tag[1]), Number(tag[2]), ascendant);
|
|
const radius = Math.hypot(Number(tag[1]) - WESTERN_CX, Number(tag[2]) - WESTERN_CY);
|
|
assert.ok(Math.abs(radius - WESTERN_R_NUMBER) < 0.2);
|
|
const delta = Math.min(forwardGap(expected, longitude), forwardGap(longitude, expected));
|
|
assert.ok(delta < 0.05, `house ${house.house} is ${longitude}, expected ${expected}`);
|
|
const cuspDelta = Math.min(forwardGap(house.longitude, longitude), forwardGap(longitude, house.longitude));
|
|
assert.ok(cuspDelta > 1, `house ${house.house} sits on its cusp`);
|
|
}
|
|
const labels = [...markup.matchAll(/<text x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/text>/g)].map((match) => ({
|
|
text: match[3] ?? "",
|
|
box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""),
|
|
}));
|
|
for (const label of labels) {
|
|
assert.equal(westernLabelInsideViewBox(label.box, WESTERN_SIZE), true, label.text);
|
|
}
|
|
for (let left = 0; left < labels.length; left += 1) {
|
|
for (let right = left + 1; right < labels.length; right += 1) {
|
|
assert.equal(
|
|
boxesOverlap(labels[left]!.box, labels[right]!.box),
|
|
false,
|
|
`${labels[left]!.text} overlaps ${labels[right]!.text}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("the western skeleton draws three rings and spokes without labels or aspects", () => {
|
|
const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, {
|
|
western: EMPTY_WESTERN_WHEEL,
|
|
skeleton: true,
|
|
}));
|
|
assert.match(markup, /data-skeleton="true"/);
|
|
assert.equal((markup.match(/<circle /g) ?? []).length, 3);
|
|
assert.equal((markup.match(/<line /g) ?? []).length, 12);
|
|
assert.doesNotMatch(markup, /<text|<path|data-tone|ASC|MC|白羊/);
|
|
});
|