Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/app/api/providers/[provider]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
{
Expand All @@ -77,9 +77,7 @@ export async function POST(request: Request, { params }: ProviderParams) {
},
);

return NextResponse.json({
positions,
});
return NextResponse.json(ephemeris);
}

if (params.provider === "chineseCalendar") {
Expand Down
63 changes: 33 additions & 30 deletions src/app/systems/wa-ha/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,38 @@
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) => ({
birthDetails: state.birthDetails,
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 (
<SystemPageLayout
title="Western / Hellenistic Astrology"
Expand All @@ -21,36 +46,14 @@ const WaHaPage = () => {
/>
<section className="grid gap-6 md:grid-cols-[1.5fr_1fr]">
<div className="rounded-lg border border-muted/50 bg-white p-4 shadow-card dark:bg-slate-900">
<h2 className="text-base font-semibold">Wheel preview</h2>
<p className="text-xs text-muted">
Placeholder wheel shows rising sign, midheaven, and houses once a provider is wired.
</p>
<svg viewBox="-150 -150 300 300" className="mt-4 h-[280px] w-full">
<circle r={120} fill="none" stroke="var(--colour-muted)" strokeWidth={2} />
{Array.from({ length: 12 }).map((_, index) => {
const angle = (index / 12) * Math.PI * 2;
return (
<line
key={index}
x1={0}
y1={0}
x2={Math.cos(angle) * 120}
y2={Math.sin(angle) * 120}
stroke="var(--colour-muted)"
strokeDasharray="4 2"
/>
);
})}
<text
x={0}
y={0}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-semibold fill-foreground"
>
{birthDetails.zodiac} · {birthDetails.houseSystem}
</text>
</svg>
<AstrologyWheel
cusps={demoCusps}
angles={demoAngles}
metadata={{
zodiac: birthDetails.zodiac.toLowerCase() as ZodiacType,
houseSystem: birthDetails.houseSystem,
}}
/>
</div>
<form className="rounded-lg border border-muted/50 bg-white p-4 text-sm shadow-card dark:bg-slate-900">
<fieldset className="mb-4">
Expand Down
5 changes: 3 additions & 2 deletions src/calculators/ephemeris.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DateTime } from "luxon";
import type { EphemerisResponse } from "@/lib/ephemeris";

export type ZodiacType = "tropical" | "sidereal";

Expand All @@ -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<PlanetPosition[]>;
) => Promise<EphemerisResponse>;
}
122 changes: 122 additions & 0 deletions src/components/ephemeris/AstrologyWheel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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<AstrologyWheelProps> = ({ cusps, angles, metadata, className }) => {
if (cusps.length === 0) {
return (
<div
className={clsx(
"flex flex-col items-center justify-center gap-2",
className,
)}
>
<h2 className="text-base font-semibold">Wheel preview</h2>
<p className="text-sm text-muted">No cusp data available.</p>
</div>
);
}

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

return (
<div className={clsx("flex flex-col", className)}>
<h2 className="text-base font-semibold">Wheel preview</h2>
<p className="text-xs text-muted">
Cusps and angles sourced from the registered ephemeris provider. Placeholder wheel shows rising sign, midheaven, and hou
ses once a provider is wired.
</p>
<svg viewBox="-150 -150 300 300" className="mt-4 h-[280px] w-full" role="img" aria-label="Astrology wheel">
<circle r={radius} fill="none" stroke="var(--colour-muted)" strokeWidth={2} />
{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 (
<g key={cusp.house}>
<line
data-testid={`cusp-${cusp.house}`}
data-longitude={cusp.longitude}
x1={0}
y1={0}
x2={Number(x.toFixed(2))}
y2={Number(y.toFixed(2))}
stroke="var(--colour-muted)"
strokeDasharray="4 2"
/>
<text
x={Number(label.x.toFixed(2))}
y={Number(label.y.toFixed(2))}
textAnchor="middle"
dominantBaseline="middle"
className="text-xs fill-muted"
>
{cusp.label ?? cusp.house}
</text>
</g>
);
})}
<circle r={radius - 20} fill="none" stroke="var(--colour-muted)" strokeWidth={1} opacity={0.4} />
<circle r={radius - 60} fill="none" stroke="var(--colour-muted)" strokeWidth={1} opacity={0.2} />
<g className="text-[10px] font-semibold fill-foreground">
<text x={0} y={-radius - 10} textAnchor="middle">
Asc {angles.ascendant.toFixed(1)}°
</text>
<text x={0} y={radius + 20} textAnchor="middle">
MC {angles.midheaven.toFixed(1)}°
</text>
</g>
<text
x={0}
y={0}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-semibold fill-foreground"
>
{zodiacLabel}
{metadata?.houseSystem ? ` · ${metadata.houseSystem}` : ""}
</text>
</svg>
</div>
);
};
29 changes: 29 additions & 0 deletions src/lib/ephemeris.ts
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 25 additions & 15 deletions src/providers/ephemeris/DemoEphemerisProvider.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<PlanetPosition[]> {
): Promise<EphemerisResponse> {
const millis = birth.toMillis();
const base = millis / (1000 * 60 * 60 * 24); // days since epoch
const positions: PlanetPosition[] = PLANETS.map((name, index) => {
Expand All @@ -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;
};
}
}
4 changes: 4 additions & 0 deletions tests/api/ephemeris.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
34 changes: 34 additions & 0 deletions tests/app/wa-ha-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WaHaPage />);

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();
});
});
Loading