From 04fd7a2e809660ee2aa6b7e19abdb35b75fc4846 Mon Sep 17 00:00:00 2001 From: Jakedoes1111 Date: Thu, 6 Nov 2025 18:08:09 +1100 Subject: [PATCH 1/2] Extend ephemeris payloads with cusps and wheel support --- src/app/api/providers/[provider]/route.ts | 6 +- src/app/systems/wa-ha/page.tsx | 65 +++++----- src/calculators/ephemeris.ts | 5 +- src/components/ephemeris/AstrologyWheel.tsx | 120 ++++++++++++++++++ src/lib/ephemeris.ts | 29 +++++ .../ephemeris/DemoEphemerisProvider.ts | 40 +++--- tests/api/ephemeris.route.test.ts | 4 + tests/app/wa-ha-page.test.tsx | 34 +++++ tests/components/AstrologyWheel.test.tsx | 45 +++++++ tests/components/DataImporter.test.tsx | 3 + tests/providers/ProviderRegistry.test.ts | 14 +- vitest.config.ts | 2 +- vitest.setup.ts | 9 ++ 13 files changed, 321 insertions(+), 55 deletions(-) create mode 100644 src/components/ephemeris/AstrologyWheel.tsx create mode 100644 src/lib/ephemeris.ts create mode 100644 tests/app/wa-ha-page.test.tsx create mode 100644 tests/components/AstrologyWheel.test.tsx diff --git a/src/app/api/providers/[provider]/route.ts b/src/app/api/providers/[provider]/route.ts index a708773..c133587 100644 --- a/src/app/api/providers/[provider]/route.ts +++ b/src/app/api/providers/[provider]/route.ts @@ -67,7 +67,7 @@ export async function POST(request: Request, { params }: ProviderParams) { const latitude = payload.coordinates?.latitude ?? 0; const longitude = payload.coordinates?.longitude ?? 0; const provider = getProvider("ephemeris"); - const positions = await provider.getPositions( + const ephemeris = await provider.getEphemeris( birth, { latitude, longitude }, { @@ -77,9 +77,7 @@ export async function POST(request: Request, { params }: ProviderParams) { }, ); - return NextResponse.json({ - positions, - }); + return NextResponse.json(ephemeris); } if (params.provider === "chineseCalendar") { diff --git a/src/app/systems/wa-ha/page.tsx b/src/app/systems/wa-ha/page.tsx index f1e66fc..2161710 100644 --- a/src/app/systems/wa-ha/page.tsx +++ b/src/app/systems/wa-ha/page.tsx @@ -3,6 +3,9 @@ import { useStore } from "@/store/useStore"; import { WarningBanner } from "@/components/WarningBanner"; import { SystemPageLayout } from "@/components/SystemPageLayout"; +import { AstrologyWheel } from "@/components/ephemeris/AstrologyWheel"; +import type { HouseCusp, EphemerisAngles } from "@/lib/ephemeris"; +import type { ZodiacType } from "@/calculators"; const WaHaPage = () => { const { birthDetails, setBirthDetails } = useStore((state) => ({ @@ -10,6 +13,28 @@ const WaHaPage = () => { setBirthDetails: state.setBirthDetails, })); + const demoCusps: HouseCusp[] = [ + { house: 1, longitude: 102.5, label: "Asc" }, + { house: 2, longitude: 128.4 }, + { house: 3, longitude: 156.2 }, + { house: 4, longitude: 186.9 }, + { house: 5, longitude: 213.7 }, + { house: 6, longitude: 245.1 }, + { house: 7, longitude: 281.0, label: "Dsc" }, + { house: 8, longitude: 308.6 }, + { house: 9, longitude: 333.3 }, + { house: 10, longitude: 12.4, label: "MC" }, + { house: 11, longitude: 39.2 }, + { house: 12, longitude: 72.8 }, + ]; + + const demoAngles: EphemerisAngles = { + ascendant: demoCusps[0].longitude, + descendant: demoCusps[6].longitude, + midheaven: demoCusps[9].longitude, + imumCoeli: (demoCusps[9].longitude + 180) % 360, + }; + return ( { description="TODO integrate Swiss Ephemeris or JPL provider. Natal positions are not generated to respect the no-invention standard." />
-
-

Wheel preview

-

- Placeholder wheel shows rising sign, midheaven, and houses once a provider is wired. -

- - - {Array.from({ length: 12 }).map((_, index) => { - const angle = (index / 12) * Math.PI * 2; - return ( - - ); - })} - - {birthDetails.zodiac} · {birthDetails.houseSystem} - - -
+
Zodiac diff --git a/src/calculators/ephemeris.ts b/src/calculators/ephemeris.ts index b1af03b..fab6e34 100644 --- a/src/calculators/ephemeris.ts +++ b/src/calculators/ephemeris.ts @@ -1,4 +1,5 @@ import type { DateTime } from "luxon"; +import type { EphemerisResponse } from "@/lib/ephemeris"; export type ZodiacType = "tropical" | "sidereal"; @@ -20,9 +21,9 @@ export interface EphemerisOptions { * TODO: integrate concrete ephemeris providers. */ export interface EphemerisProvider { - getPositions: ( + getEphemeris: ( birth: DateTime, coordinates: { latitude: number; longitude: number }, options: EphemerisOptions, - ) => Promise; + ) => Promise; } diff --git a/src/components/ephemeris/AstrologyWheel.tsx b/src/components/ephemeris/AstrologyWheel.tsx new file mode 100644 index 0000000..6c656d9 --- /dev/null +++ b/src/components/ephemeris/AstrologyWheel.tsx @@ -0,0 +1,120 @@ +import type { FC } from "react"; +import clsx from "clsx"; +import type { EphemerisAngles, EphemerisMetadata, HouseCusp } from "@/lib/ephemeris"; + +const radius = 120; +const labelRadius = radius - 30; + +const toRadians = (degrees: number) => (degrees * Math.PI) / 180; +const wrapDegrees = (degrees: number) => { + const normalised = degrees % 360; + return normalised < 0 ? normalised + 360 : normalised; +}; + +const midpoint = (start: number, end: number) => { + const startWrapped = wrapDegrees(start); + const endWrapped = wrapDegrees(end); + let delta = endWrapped - startWrapped; + if (delta <= 0) { + delta += 360; + } + return wrapDegrees(startWrapped + delta / 2); +}; + +const polarToCartesian = (deg: number, r: number) => { + const radians = toRadians(deg); + return { + x: Math.cos(radians) * r, + y: Math.sin(radians) * r, + }; +}; + +export interface AstrologyWheelProps { + cusps: HouseCusp[]; + angles: EphemerisAngles; + metadata?: EphemerisMetadata; + className?: string; +} + +export const AstrologyWheel: FC = ({ cusps, angles, metadata, className }) => { + if (cusps.length === 0) { + return ( +
+

No cusp data available.

+
+ ); + } + + const sortedCusps = [...cusps].sort((a, b) => a.house - b.house); + const zodiacLabel = metadata?.zodiac === "sidereal" + ? "Sidereal" + : metadata?.zodiac === "tropical" + ? "Tropical" + : "Zodiac"; + + return ( +
+

Wheel preview

+

+ Cusps and angles sourced from the registered ephemeris provider. +

+ + + {sortedCusps.map((cusp, index) => { + const nextCusp = sortedCusps[(index + 1) % sortedCusps.length]; + const labelAngle = midpoint(cusp.longitude, nextCusp.longitude); + const { x, y } = polarToCartesian(cusp.longitude, radius); + const label = polarToCartesian(labelAngle, labelRadius); + return ( + + + + {cusp.label ?? cusp.house} + + + ); + })} + + + + + Asc {angles.ascendant.toFixed(1)}° + + + MC {angles.midheaven.toFixed(1)}° + + + + {zodiacLabel} + {metadata?.houseSystem ? ` · ${metadata.houseSystem}` : ""} + + +
+ ); +}; diff --git a/src/lib/ephemeris.ts b/src/lib/ephemeris.ts new file mode 100644 index 0000000..585f4c3 --- /dev/null +++ b/src/lib/ephemeris.ts @@ -0,0 +1,29 @@ +import type { PlanetPosition } from "@/calculators/ephemeris"; +import type { ZodiacType } from "@/calculators/ephemeris"; + +export interface HouseCusp { + house: number; + longitude: number; + label?: string; +} + +export interface EphemerisAngles { + ascendant: number; + descendant: number; + midheaven: number; + imumCoeli: number; +} + +export interface EphemerisMetadata { + zodiac: ZodiacType; + houseSystem?: string; + ayanamsa?: string; + provider?: string; +} + +export interface EphemerisResponse { + positions: PlanetPosition[]; + cusps: HouseCusp[]; + angles: EphemerisAngles; + metadata: EphemerisMetadata; +} diff --git a/src/providers/ephemeris/DemoEphemerisProvider.ts b/src/providers/ephemeris/DemoEphemerisProvider.ts index f460fc8..71ee115 100644 --- a/src/providers/ephemeris/DemoEphemerisProvider.ts +++ b/src/providers/ephemeris/DemoEphemerisProvider.ts @@ -1,5 +1,6 @@ import type { DateTime } from "luxon"; import type { EphemerisOptions, EphemerisProvider, PlanetPosition } from "@/calculators"; +import type { EphemerisResponse } from "@/lib/ephemeris"; const PLANETS = [ "Sun", @@ -25,11 +26,11 @@ const wrapDegrees = (value: number) => { * exercise visualisations without sourcing licensed planetary data. */ export class DemoEphemerisProvider implements EphemerisProvider { - async getPositions( + async getEphemeris( birth: DateTime, coordinates: { latitude: number; longitude: number }, options: EphemerisOptions, - ): Promise { + ): Promise { const millis = birth.toMillis(); const base = millis / (1000 * 60 * 60 * 24); // days since epoch const positions: PlanetPosition[] = PLANETS.map((name, index) => { @@ -42,20 +43,29 @@ export class DemoEphemerisProvider implements EphemerisProvider { }; }); - // Always include Ascendant/Midheaven placeholders for UI consumption. - positions.push( - { - name: options.zodiac === "sidereal" ? "Ascendant (sidereal)" : "Ascendant", - longitude: wrapDegrees(coordinates.longitude + 90), - house: 1, + const ascendant = wrapDegrees(coordinates.longitude + 90); + const midheaven = wrapDegrees(coordinates.longitude + 180); + + const cusps = Array.from({ length: 12 }, (_, index) => ({ + house: index + 1, + longitude: wrapDegrees(ascendant + index * 30 + (options.houseSystem ? index * 0.5 : 0)), + })); + + return { + positions, + cusps, + angles: { + ascendant, + descendant: wrapDegrees(ascendant + 180), + midheaven, + imumCoeli: wrapDegrees(midheaven + 180), }, - { - name: "Midheaven", - longitude: wrapDegrees(coordinates.longitude + 180), - house: 10, + metadata: { + zodiac: options.zodiac, + houseSystem: options.houseSystem, + ayanamsa: options.ayanamsa, + provider: "DemoEphemerisProvider", }, - ); - - return positions; + }; } } diff --git a/tests/api/ephemeris.route.test.ts b/tests/api/ephemeris.route.test.ts index 472ff84..d66e415 100644 --- a/tests/api/ephemeris.route.test.ts +++ b/tests/api/ephemeris.route.test.ts @@ -26,5 +26,9 @@ describe("POST /api/providers/ephemeris", () => { expect(Array.isArray(json.positions)).toBe(true); expect(json.positions.length).toBeGreaterThan(0); expect(json.positions[0]).toHaveProperty("longitude"); + expect(json).toHaveProperty("cusps"); + expect(Array.isArray(json.cusps)).toBe(true); + expect(json.cusps[0]).toHaveProperty("house"); + expect(json).toHaveProperty("angles.ascendant"); }); }); diff --git a/tests/app/wa-ha-page.test.tsx b/tests/app/wa-ha-page.test.tsx new file mode 100644 index 0000000..b7d2fb9 --- /dev/null +++ b/tests/app/wa-ha-page.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/store/useStore", () => { + const birthDetails = { + zodiac: "Tropical", + houseSystem: "Placidus", + ayanamsa: "", + birthDate: "1992-09-01", + birthTime: "06:03", + timezone: "UTC", + latitude: null, + longitude: null, + timezoneConfirmed: false, + }; + + return { + useStore: (selector: (state: unknown) => unknown) => + selector({ birthDetails, setBirthDetails: vi.fn() }), + }; +}); + +import WaHaPage from "@/app/systems/wa-ha/page"; + +describe("WA/HA page", () => { + it("surfaces demo cusps on the astrology wheel", () => { + render(); + + expect(screen.getByRole("img", { name: /astrology wheel/i })).toBeInTheDocument(); + const cuspLine = screen.getByTestId("cusp-10"); + expect(cuspLine).toHaveAttribute("data-longitude", "12.4"); + expect(screen.getByText(/Cusps and angles sourced/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/AstrologyWheel.test.tsx b/tests/components/AstrologyWheel.test.tsx new file mode 100644 index 0000000..7befd35 --- /dev/null +++ b/tests/components/AstrologyWheel.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AstrologyWheel } from "@/components/ephemeris/AstrologyWheel"; +import type { HouseCusp } from "@/lib/ephemeris"; + +const sampleCusps: HouseCusp[] = [ + { house: 1, longitude: 123.4 }, + { house: 2, longitude: 158.1 }, + { house: 3, longitude: 201.9 }, + { house: 4, longitude: 249.5 }, + { house: 5, longitude: 292.3 }, + { house: 6, longitude: 327.8 }, + { house: 7, longitude: 3.2 }, + { house: 8, longitude: 42.6 }, + { house: 9, longitude: 83.5 }, + { house: 10, longitude: 119.7 }, + { house: 11, longitude: 151.8 }, + { house: 12, longitude: 188.4 }, +]; + +describe("AstrologyWheel", () => { + it("renders provided cusps without normalising to 30°", () => { + render( + , + ); + + expect(screen.getByTestId("cusp-2")).toHaveAttribute("data-longitude", "158.1"); + expect(screen.getByTestId("cusp-7")).toHaveAttribute("data-longitude", "3.2"); + }); + + it("shows an empty state when no cusps are provided", () => { + render( + , + ); + + expect(screen.getByText(/No cusp data available/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/DataImporter.test.tsx b/tests/components/DataImporter.test.tsx index 18de2e6..13ad77c 100644 --- a/tests/components/DataImporter.test.tsx +++ b/tests/components/DataImporter.test.tsx @@ -58,6 +58,9 @@ describe("DataImporter", () => { const input = screen.getByLabelText(/Drop file or select/i); const file = new File(["person_id"], "sample.csv", { type: "text/csv" }); + Object.defineProperty(file, "text", { + value: vi.fn(() => Promise.resolve("person_id")), + }); fireEvent.change(input, { target: { files: [file] } }); diff --git a/tests/providers/ProviderRegistry.test.ts b/tests/providers/ProviderRegistry.test.ts index 3f9e4f3..5818331 100644 --- a/tests/providers/ProviderRegistry.test.ts +++ b/tests/providers/ProviderRegistry.test.ts @@ -17,7 +17,19 @@ describe("ProviderRegistry", () => { it("returns registered provider", () => { resetProviders(); const stub: EphemerisProvider = { - getPositions: async () => [], + getEphemeris: async () => ({ + positions: [], + cusps: [], + angles: { + ascendant: 0, + descendant: 180, + midheaven: 90, + imumCoeli: 270, + }, + metadata: { + zodiac: "tropical", + }, + }), }; registerProvider({ key: "ephemeris", provider: stub }); expect(getProvider("ephemeris")).toBe(stub); diff --git a/vitest.config.ts b/vitest.config.ts index 286548b..e650929 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ }, test: { environment: "jsdom", - include: ["tests/**/*.test.ts"], + include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], exclude: ["tests/e2e/**", "playwright.config.ts"], globals: true, setupFiles: "./vitest.setup.ts", diff --git a/vitest.setup.ts b/vitest.setup.ts index e1e822c..261a6a3 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -10,3 +10,12 @@ if (typeof window !== "undefined" && !("ResizeObserver" in window)) { // @ts-expect-error jsdom lacks ResizeObserver; provide minimal stub for tests window.ResizeObserver = ResizeObserverStub; } + +if (typeof File !== "undefined" && !("text" in File.prototype) && "arrayBuffer" in File.prototype) { + // Polyfill File#text using arrayBuffer for environments lacking the API (e.g. jsdom < 20) + // @ts-expect-error augmenting File prototype for tests + File.prototype.text = async function text() { + const buffer = await this.arrayBuffer(); + return new TextDecoder().decode(buffer); + }; +} From f50ae351e04f4f7897d09e5fa2d97290c269fc36 Mon Sep 17 00:00:00 2001 From: Jakedoes1111 Date: Thu, 6 Nov 2025 18:23:12 +1100 Subject: [PATCH 2/2] Resolve astrology wheel layout conflicts --- src/app/systems/wa-ha/page.tsx | 18 ++++++++++-------- src/components/ephemeris/AstrologyWheel.tsx | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/app/systems/wa-ha/page.tsx b/src/app/systems/wa-ha/page.tsx index 2161710..a51882e 100644 --- a/src/app/systems/wa-ha/page.tsx +++ b/src/app/systems/wa-ha/page.tsx @@ -45,14 +45,16 @@ const WaHaPage = () => { description="TODO integrate Swiss Ephemeris or JPL provider. Natal positions are not generated to respect the no-invention standard." />
- +
+ +
Zodiac diff --git a/src/components/ephemeris/AstrologyWheel.tsx b/src/components/ephemeris/AstrologyWheel.tsx index 6c656d9..8dc5481 100644 --- a/src/components/ephemeris/AstrologyWheel.tsx +++ b/src/components/ephemeris/AstrologyWheel.tsx @@ -39,7 +39,13 @@ export interface AstrologyWheelProps { export const AstrologyWheel: FC = ({ cusps, angles, metadata, className }) => { if (cusps.length === 0) { return ( -
+
+

Wheel preview

No cusp data available.

); @@ -53,15 +59,11 @@ export const AstrologyWheel: FC = ({ cusps, angles, metadat : "Zodiac"; return ( -
+

Wheel preview

- Cusps and angles sourced from the registered ephemeris provider. + Cusps and angles sourced from the registered ephemeris provider. Placeholder wheel shows rising sign, midheaven, and hou + ses once a provider is wired.