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:
@@ -4,7 +4,9 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
|
||||
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
|
||||
import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx";
|
||||
import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
|
||||
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
@@ -14,6 +16,7 @@ import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
|
||||
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
|
||||
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
|
||||
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
|
||||
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
@@ -370,6 +373,64 @@ test("chart svgs omit the invalid height attribute and western height stays in c
|
||||
assert.match(globalStyles, /\.chart-page-western-svg \{[^}]*height: auto/);
|
||||
});
|
||||
|
||||
test("the dasha tab folds every track to the current period and keeps a now card", async () => {
|
||||
const view = await okView(golden.western);
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||||
assert.equal((markup.match(/chart-page-dasha-now/g) ?? []).length, 2);
|
||||
assert.match(markup, /现在/);
|
||||
assert.match(markup, /<dt>小运剩余<\/dt><dd>\d+ 个月<\/dd>/);
|
||||
const periods = [...view.dasha.vimshottari.periods, ...view.dasha.chara.periods];
|
||||
assert.equal(periods.some((period) => period.zeroYear), false);
|
||||
assert.equal((markup.match(/data-dasha-segment=/g) ?? []).length, periods.length);
|
||||
const details = markup.match(/<details/g) ?? [];
|
||||
const open = markup.match(/<details open/g) ?? [];
|
||||
assert.equal(open.length, periods.filter((period) => period.current).length);
|
||||
assert.ok(details.length > open.length);
|
||||
assert.match(cssDeclarations(".chart-page-dasha-period summary", globalStyles), /min-height:\s*44px/);
|
||||
assert.match(globalStyles, /\.chart-page-dasha-period details:not\(\[open\]\) > ul \{ display: none; \}/);
|
||||
});
|
||||
|
||||
test("the western tab lists every planet and filters aspects for the selected body", async () => {
|
||||
const view = await okView(golden.western);
|
||||
assert.equal(view.western.status, "ok");
|
||||
if (view.western.status !== "ok") return;
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartWesternTab, { view }));
|
||||
assert.equal((markup.match(/data-planet-row=/g) ?? []).length, view.western.planets.length);
|
||||
assert.equal((markup.match(/data-aspect-row=/g) ?? []).length, view.western.aspects.length);
|
||||
assert.match(markup, /viewBox="0 0 520 520"/);
|
||||
assert.match(markup, /data-tone=/);
|
||||
assert.doesNotMatch(markup, /<text[^>]*>升<\/text>/);
|
||||
assert.match(markup, /上升 /);
|
||||
assert.match(markup, /太阳 /);
|
||||
assert.match(markup, /月亮 /);
|
||||
assert.match(markup, /星群 摩羯/);
|
||||
assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/);
|
||||
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/);
|
||||
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/);
|
||||
assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/);
|
||||
const sun = view.western.planets.find((planet) => planet.id === "sun");
|
||||
assert.ok(sun);
|
||||
const filtered = renderToStaticMarkup(React.createElement(ChartWesternTab, { view, initialSelectedId: sun.id }));
|
||||
const expected = view.western.aspects.filter((aspect) => aspect.left === sun.label || aspect.right === sun.label);
|
||||
assert.equal((filtered.match(/data-aspect-row=/g) ?? []).length, expected.length);
|
||||
assert.ok(expected.length < view.western.aspects.length);
|
||||
assert.match(filtered, /data-filtering="true"/);
|
||||
assert.match(filtered, /is-selected/);
|
||||
const svg = markup.slice(markup.indexOf("<svg"), markup.indexOf("</svg>"));
|
||||
const labels = [...svg.matchAll(/<text x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/text>/g)].map((match) => ({
|
||||
text: match[3] ?? "",
|
||||
box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""),
|
||||
}));
|
||||
for (let left = 0; left < labels.length; left += 1) {
|
||||
for (let right = left + 1; right < labels.length; right += 1) {
|
||||
const a = labels[left]!.box;
|
||||
const b = labels[right]!.box;
|
||||
const overlap = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
|
||||
assert.equal(overlap, false, `${labels[left]!.text} overlaps ${labels[right]!.text}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the (app) layout mounts one sidebar for home and the four secondary routes", () => {
|
||||
// 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表
|
||||
// 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面
|
||||
|
||||
@@ -95,6 +95,22 @@ test("zero antardasha weights on a positive mahadasha do not produce an invalid
|
||||
}
|
||||
});
|
||||
|
||||
test("a zero-year period is grey, closed, and absent from the timeline", () => {
|
||||
const view = viewFrom(golden.chara);
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||||
const zero = markup.split("<li").find((item) => item.includes(CHARA_ZERO_YEAR_LABEL) && item.includes("射手"));
|
||||
assert.ok(zero);
|
||||
assert.match(zero, /is-zero/);
|
||||
assert.doesNotMatch(zero, /<details/);
|
||||
assert.match(zero, new RegExp(CHARA_ZERO_YEAR_NOTE));
|
||||
const tracks = markup.split("chart-page-dasha-track").slice(1);
|
||||
assert.equal(tracks.length, 2);
|
||||
const charaSegments = tracks[1]?.match(/data-dasha-segment=/g) ?? [];
|
||||
assert.equal(charaSegments.length, view.dasha.chara.periods.filter((period) => !period.zeroYear).length);
|
||||
assert.equal(charaSegments.length, 11);
|
||||
assert.doesNotMatch(tracks[1] ?? "", /<details[^>]*open[^>]*>[\s\S]*0 年/);
|
||||
});
|
||||
|
||||
test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => {
|
||||
const view = viewFrom(golden.chara);
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"source": "scripts.western_chart_engine.build_tropical_natal_chart",
|
||||
"birth": "1990-01-01 12:00 timezone 8 latitude 39.9042 longitude 116.4074 house P",
|
||||
"ascendant": { "longitude": 11.837301, "sign": "Aries", "degree_in_sign": 11.837301 },
|
||||
"mc": { "longitude": 276.388945, "sign": "Capricorn", "degree_in_sign": 6.388945 },
|
||||
"planets": [
|
||||
{ "id": "sun", "label": "太阳", "longitude": 280.474427, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 10.474427, "house": 10, "retrograde": false },
|
||||
{ "id": "moon", "label": "月亮", "longitude": 328.788668, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 28.788668, "house": 12, "retrograde": false },
|
||||
{ "id": "mercury", "label": "水星", "longitude": 295.760024, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 25.760024, "house": 10, "retrograde": true },
|
||||
{ "id": "venus", "label": "金星", "longitude": 306.262927, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 6.262927, "house": 11, "retrograde": true },
|
||||
{ "id": "mars", "label": "火星", "longitude": 249.765531, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 9.765531, "house": 8, "retrograde": false },
|
||||
{ "id": "jupiter", "label": "木星", "longitude": 95.193582, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 5.193582, "house": 3, "retrograde": true },
|
||||
{ "id": "saturn", "label": "土星", "longitude": 285.618103, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 15.618103, "house": 10, "retrograde": false },
|
||||
{ "id": "uranus", "label": "天王", "longitude": 275.765468, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 5.765468, "house": 9, "retrograde": false },
|
||||
{ "id": "neptune", "label": "海王", "longitude": 282.025482, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 12.025482, "house": 10, "retrograde": false },
|
||||
{ "id": "pluto", "label": "冥王", "longitude": 227.084033, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 17.084033, "house": 7, "retrograde": false },
|
||||
{ "id": "true_node", "label": "北交", "longitude": 316.861653, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 16.861653, "house": 11, "retrograde": false }
|
||||
],
|
||||
"houses": [
|
||||
{ "house": 1, "longitude": 11.837301, "sign": "Aries", "signLabel": "白羊", "degreeInSign": 11.837301 },
|
||||
{ "house": 2, "longitude": 49.703156, "sign": "Taurus", "signLabel": "金牛", "degreeInSign": 19.703156 },
|
||||
{ "house": 3, "longitude": 75.027826, "sign": "Gemini", "signLabel": "双子", "degreeInSign": 15.027826 },
|
||||
{ "house": 4, "longitude": 96.388945, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 6.388945 },
|
||||
{ "house": 5, "longitude": 118.786626, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 28.786626 },
|
||||
{ "house": 6, "longitude": 147.810722, "sign": "Leo", "signLabel": "狮子", "degreeInSign": 27.810722 },
|
||||
{ "house": 7, "longitude": 191.837301, "sign": "Libra", "signLabel": "天秤", "degreeInSign": 11.837301 },
|
||||
{ "house": 8, "longitude": 229.703156, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 19.703156 },
|
||||
{ "house": 9, "longitude": 255.027826, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 15.027826 },
|
||||
{ "house": 10, "longitude": 276.388945, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 6.388945 },
|
||||
{ "house": 11, "longitude": 298.786626, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 28.786626 },
|
||||
{ "house": 12, "longitude": 327.810722, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 27.810722 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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|白羊/);
|
||||
});
|
||||
Reference in New Issue
Block a user