caelus-wheel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # caelus-wheel
2
+
3
+ React SVG chart wheel for [caelus](https://github.com/heavyblotto/caelus).
4
+ SSR-safe, zero runtime dependencies (react is a peer), ~3.4 KB gzipped.
5
+
6
+ ```tsx
7
+ import { ChartWheel } from "caelus-wheel";
8
+
9
+ <ChartWheel
10
+ chart={chart} // the Chart object from caelus, as-is
11
+ size={520} // px, square
12
+ showAspects={true}
13
+ aspectTypes={["conjunction", "sextile", "square", "trine", "opposition"]}
14
+ theme={{ axis: "#8a7fd4" }} // Partial<WheelTheme>; dark default
15
+ />
16
+ ```
17
+
18
+ ## What it draws
19
+
20
+ - **Zodiac ring** — sign glyphs, sign boundaries, 1°/5°/10° tick marks.
21
+ - **House ring** — cusps from `chart.cusps` (all four systems), house
22
+ numbers, AC/MC/DC/IC emphasized and labeled.
23
+ - **Planets** — glyph, degree°minute label, ℞ retrograde mark, a pointer
24
+ tick at the true longitude. Bodies within ~6.5° fan out radially with a
25
+ thin connector back to the true position, preserving zodiacal order —
26
+ stelliums stay readable.
27
+ - **Aspect lines** — chords in the inner circle, colored by type, solid
28
+ for hard aspects / dashed for soft, opacity scaled by orb tightness.
29
+
30
+ Orientation is the Western convention: ASC at 9 o'clock, longitudes
31
+ counterclockwise.
32
+
33
+ ## Notes
34
+
35
+ - `mean_node` is hidden by default (it sits ~1° from the true node and
36
+ doubles the glyph); pass `bodies={Object.keys(chart.bodies)}` to show
37
+ every body, or any subset to filter.
38
+ - Glyphs are Unicode astrological characters embedded as SVG text. If a
39
+ host font lacks one, override per body: `glyphs={{ chiron: "Ch" }}`.
40
+ - Pure render: no hooks, no client-only APIs — works in server components,
41
+ static export, and `renderToStaticMarkup` (the test suite renders real
42
+ engine charts exactly that way).
@@ -0,0 +1,66 @@
1
+ /**
2
+ * caelus-wheel — React SVG chart wheel.
3
+ *
4
+ * Pure render, SSR-safe (no client-only APIs, no hooks, no effects).
5
+ * Zero runtime dependencies; react is a peer.
6
+ *
7
+ * Orientation follows Western convention: ASC at 9 o'clock, zodiac
8
+ * counterclockwise. Glyphs are Unicode astrological characters embedded as
9
+ * SVG text — if a host font lacks one, the two-letter fallback in GLYPHS
10
+ * can be substituted via the `glyphs` prop.
11
+ */
12
+ import type { ReactElement } from "react";
13
+ export interface WheelPosition {
14
+ lon: number;
15
+ retrograde: boolean;
16
+ signDeg: number;
17
+ }
18
+ export interface WheelAspect {
19
+ a: string;
20
+ b: string;
21
+ aspect: string;
22
+ orb: number;
23
+ }
24
+ export interface WheelChart {
25
+ bodies: Record<string, WheelPosition>;
26
+ angles: {
27
+ asc: number;
28
+ mc: number;
29
+ };
30
+ cusps: number[];
31
+ aspects: WheelAspect[];
32
+ }
33
+ export interface WheelTheme {
34
+ background: string;
35
+ ring: string;
36
+ axis: string;
37
+ signText: string;
38
+ planetText: string;
39
+ labelText: string;
40
+ houseText: string;
41
+ aspectColors: Record<string, string>;
42
+ fontFamily: string;
43
+ }
44
+ export declare const DARK_THEME: WheelTheme;
45
+ export declare const GLYPHS: Record<string, string>;
46
+ export interface ChartWheelProps {
47
+ /** The Chart object from caelus, as-is. */
48
+ chart: WheelChart;
49
+ /** Square size in px. */
50
+ size?: number;
51
+ showAspects?: boolean;
52
+ aspectTypes?: string[];
53
+ /** Bodies to draw; defaults to every body in the chart except mean_node
54
+ * (true node is drawn; the two sit ~1° apart and double the glyph). */
55
+ bodies?: string[];
56
+ theme?: Partial<WheelTheme>;
57
+ glyphs?: Record<string, string>;
58
+ }
59
+ /**
60
+ * Fan out display angles so no two bodies sit closer than minSep degrees,
61
+ * preserving zodiacal order. Circular: cut at the largest gap, cluster
62
+ * linearly, spread each cluster around its midpoint, merge clusters that
63
+ * collide after spreading, repeat until stable.
64
+ */
65
+ export declare function spreadAngles(lons: number[], minSep: number): number[];
66
+ export declare function ChartWheel({ chart, size, showAspects, aspectTypes, bodies, theme, glyphs, }: ChartWheelProps): ReactElement;
@@ -0,0 +1,187 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export const DARK_THEME = {
3
+ background: "transparent",
4
+ ring: "#3a3a44",
5
+ axis: "#8a7fd4",
6
+ signText: "#9a93c4",
7
+ planetText: "#e8e6f0",
8
+ labelText: "#8d8a99",
9
+ houseText: "#6a6775",
10
+ aspectColors: {
11
+ conjunction: "#b8b4c8",
12
+ opposition: "#c0564f",
13
+ square: "#c0564f",
14
+ trine: "#4f8fc0",
15
+ sextile: "#4fb09a",
16
+ },
17
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
18
+ };
19
+ export const GLYPHS = {
20
+ sun: "☉", moon: "☽", mercury: "☿", venus: "♀",
21
+ mars: "♂", jupiter: "♃", saturn: "♄", uranus: "♅",
22
+ neptune: "♆", pluto: "♇", chiron: "⚷",
23
+ true_node: "☊", mean_node: "☊",
24
+ };
25
+ const SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍",
26
+ "♎", "♏", "♐", "♑", "♒", "♓"];
27
+ const HARD_ASPECTS = new Set(["conjunction", "square", "opposition"]);
28
+ const MAX_ORB = {
29
+ conjunction: 8, sextile: 4, square: 7, trine: 7, opposition: 8,
30
+ };
31
+ /** Python-semantics modulo (result sign follows the divisor). */
32
+ const mod = (a, n) => ((a % n) + n) % n;
33
+ /**
34
+ * Fan out display angles so no two bodies sit closer than minSep degrees,
35
+ * preserving zodiacal order. Circular: cut at the largest gap, cluster
36
+ * linearly, spread each cluster around its midpoint, merge clusters that
37
+ * collide after spreading, repeat until stable.
38
+ */
39
+ export function spreadAngles(lons, minSep) {
40
+ const n = lons.length;
41
+ if (n <= 1)
42
+ return [...lons];
43
+ // cannot fit at all: shrink separation to what the circle allows
44
+ const sep = Math.min(minSep, 360 / n);
45
+ const order = lons.map((lon, i) => ({ lon: mod(lon, 360), i }))
46
+ .sort((a, b) => a.lon - b.lon);
47
+ // rotate so the largest gap is between the last and first element
48
+ let cut = 0;
49
+ let biggest = -1;
50
+ for (let k = 0; k < n; k++) {
51
+ const gap = mod(order[(k + 1) % n].lon - order[k].lon, 360);
52
+ if (gap > biggest) {
53
+ biggest = gap;
54
+ cut = (k + 1) % n;
55
+ }
56
+ }
57
+ const seq = [...order.slice(cut), ...order.slice(0, cut)];
58
+ // unwrap to a monotonic line starting at the first element
59
+ const line = seq.map((e) => e.lon);
60
+ for (let k = 1; k < n; k++) {
61
+ while (line[k] < line[k - 1])
62
+ line[k] += 360;
63
+ }
64
+ // clusters as [start, end) index ranges; spread each evenly around the
65
+ // midpoint of its true positions, merge clusters that collide, repeat
66
+ const spread = (cl) => {
67
+ const m = cl.e - cl.s;
68
+ const mid = (line[cl.s] + line[cl.e - 1]) / 2;
69
+ return Array.from({ length: m }, (_, j) => mid + (j - (m - 1) / 2) * sep);
70
+ };
71
+ let clusters = line.map((_, k) => ({ s: k, e: k + 1 }));
72
+ let positions = clusters.map(spread);
73
+ for (let pass = 0; pass < n; pass++) {
74
+ let merged = false;
75
+ const nc = [];
76
+ const np = [];
77
+ for (let k = 0; k < clusters.length; k++) {
78
+ const prev = np[np.length - 1];
79
+ if (prev && positions[k][0] - prev[prev.length - 1] < sep - 1e-9) {
80
+ nc[nc.length - 1].e = clusters[k].e;
81
+ np[np.length - 1] = spread(nc[nc.length - 1]);
82
+ merged = true;
83
+ }
84
+ else {
85
+ nc.push({ ...clusters[k] });
86
+ np.push(positions[k]);
87
+ }
88
+ }
89
+ clusters = nc;
90
+ positions = np;
91
+ if (!merged)
92
+ break;
93
+ }
94
+ const out = new Array(n);
95
+ clusters.forEach((cl, k) => {
96
+ for (let j = cl.s; j < cl.e; j++)
97
+ out[seq[j].i] = mod(positions[k][j - cl.s], 360);
98
+ });
99
+ return out;
100
+ }
101
+ export function ChartWheel({ chart, size = 520, showAspects = true, aspectTypes = ["conjunction", "sextile", "square", "trine", "opposition"], bodies, theme, glyphs, }) {
102
+ const T = { ...DARK_THEME, ...theme,
103
+ aspectColors: { ...DARK_THEME.aspectColors, ...(theme?.aspectColors ?? {}) } };
104
+ const G = { ...GLYPHS, ...glyphs };
105
+ const asc = chart.angles.asc;
106
+ const c = size / 2;
107
+ const R = (size / 2) * 0.96;
108
+ // ASC at 9 o'clock, longitudes counterclockwise
109
+ const pt = (lon, r) => {
110
+ const a = ((lon - asc + 180) * Math.PI) / 180;
111
+ return [c + r * R * Math.cos(a), c - r * R * Math.sin(a)];
112
+ };
113
+ const fix = (v) => Math.round(v * 100) / 100;
114
+ const line = (lon, r0, r1, props, key) => {
115
+ const [x1, y1] = pt(lon, r0);
116
+ const [x2, y2] = pt(lon, r1);
117
+ return _jsx("line", { x1: fix(x1), y1: fix(y1), x2: fix(x2), y2: fix(y2), ...props }, key);
118
+ };
119
+ const text = (lon, r, content, fontSize, fill, key, extra = {}) => {
120
+ const [x, y] = pt(lon, r);
121
+ return (_jsx("text", { x: fix(x), y: fix(y), fontSize: fontSize, fill: fill, textAnchor: "middle", dominantBaseline: "central", fontFamily: T.fontFamily, ...extra, children: content }, key));
122
+ };
123
+ const names = (bodies ?? Object.keys(chart.bodies).filter((b) => b !== "mean_node"))
124
+ .filter((b) => chart.bodies[b] !== undefined);
125
+ const trueLons = names.map((b) => chart.bodies[b].lon);
126
+ const dispLons = spreadAngles(trueLons, 6.5);
127
+ const el = [];
128
+ // ring circles
129
+ for (const [r, key] of [[1.0, "outer"], [0.84, "zodiac-in"], [0.70, "house-in"],
130
+ [0.50, "aspect"]]) {
131
+ el.push(_jsx("circle", { cx: c, cy: c, r: fix(r * R), fill: "none", stroke: T.ring, strokeWidth: r === 1.0 ? 1.5 : 1 }, `ring-${key}`));
132
+ }
133
+ // zodiac: sign boundaries, glyphs, ticks
134
+ for (let s = 0; s < 12; s++) {
135
+ el.push(line(s * 30, 0.84, 1.0, { stroke: T.ring, strokeWidth: 1 }, `sb-${s}`));
136
+ el.push(text(s * 30 + 15, 0.92, SIGN_GLYPHS[s], size * 0.045, T.signText, `sg-${s}`));
137
+ }
138
+ for (let d = 0; d < 360; d++) {
139
+ const len = d % 10 === 0 ? 0.035 : d % 5 === 0 ? 0.028 : 0.016;
140
+ el.push(line(d, 0.84, 0.84 + len, { stroke: T.ring, strokeWidth: d % 5 === 0 ? 1 : 0.5 }, `tick-${d}`));
141
+ }
142
+ // house cusps + numbers; axes emphasized
143
+ const cusps = chart.cusps;
144
+ const axes = [
145
+ [asc, "AC"], [chart.angles.mc, "MC"],
146
+ [mod(asc + 180, 360), "DC"], [mod(chart.angles.mc + 180, 360), "IC"],
147
+ ];
148
+ for (let i = 0; i < 12; i++) {
149
+ el.push(line(cusps[i], 0.50, 0.84, { stroke: T.ring, strokeWidth: 1 }, `cusp-${i}`));
150
+ const arc = mod(cusps[(i + 1) % 12] - cusps[i], 360);
151
+ el.push(text(cusps[i] + arc / 2, 0.77, String(i + 1), size * 0.026, T.houseText, `hn-${i}`));
152
+ }
153
+ for (const [lon, label] of axes) {
154
+ el.push(line(lon, 0.50, 1.0, { stroke: T.axis, strokeWidth: 2 }, `axis-${label}`));
155
+ el.push(text(lon, 1.045, label, size * 0.026, T.axis, `axl-${label}`));
156
+ }
157
+ // aspect lines (under the planet layer)
158
+ if (showAspects) {
159
+ const want = new Set(aspectTypes);
160
+ const drawn = new Set(names);
161
+ for (const a of chart.aspects) {
162
+ if (!want.has(a.aspect) || !drawn.has(a.a) || !drawn.has(a.b))
163
+ continue;
164
+ const [x1, y1] = pt(chart.bodies[a.a].lon, 0.50);
165
+ const [x2, y2] = pt(chart.bodies[a.b].lon, 0.50);
166
+ const tightness = Math.max(0, 1 - a.orb / (MAX_ORB[a.aspect] ?? 8));
167
+ el.push(_jsx("line", { x1: fix(x1), y1: fix(y1), x2: fix(x2), y2: fix(y2), stroke: T.aspectColors[a.aspect] ?? T.ring, strokeWidth: 1 + tightness, strokeDasharray: HARD_ASPECTS.has(a.aspect) ? undefined : "4 3", opacity: fix(0.25 + 0.65 * tightness) }, `asp-${a.a}-${a.aspect}-${a.b}`));
168
+ }
169
+ }
170
+ // planets: pointer tick at true longitude, glyph + label at fanned angle
171
+ names.forEach((b, i) => {
172
+ const p = chart.bodies[b];
173
+ const disp = dispLons[i];
174
+ el.push(line(p.lon, 0.815, 0.84, { stroke: T.planetText, strokeWidth: 1.2 }, `pt-${b}`));
175
+ // connector when the glyph was displaced off its true longitude
176
+ if (Math.abs(mod(disp - p.lon + 180, 360) - 180) > 0.75) {
177
+ const [x1, y1] = pt(p.lon, 0.815);
178
+ const [x2, y2] = pt(disp, 0.71);
179
+ el.push(_jsx("line", { x1: fix(x1), y1: fix(y1), x2: fix(x2), y2: fix(y2), stroke: T.labelText, strokeWidth: 0.5, opacity: 0.5 }, `conn-${b}`));
180
+ }
181
+ el.push(text(disp, 0.655, G[b] ?? b.slice(0, 2).toUpperCase(), size * 0.05, T.planetText, `pg-${b}`));
182
+ const deg = Math.floor(p.signDeg);
183
+ const min = String(Math.floor(mod(p.signDeg, 1) * 60)).padStart(2, "0");
184
+ el.push(text(disp, 0.585, `${deg}°${min}'${p.retrograde ? "℞" : ""}`, size * 0.024, T.labelText, `pl-${b}`));
185
+ });
186
+ return (_jsx("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, role: "img", "aria-label": "astrological chart wheel", style: { background: T.background }, children: el }));
187
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "caelus-wheel",
3
+ "version": "0.1.0",
4
+ "description": "React SVG chart wheel for caelus: zodiac, houses, planets with collision avoidance, aspect lines. SSR-safe, zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "files": [
9
+ "dist/src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "node dist/test/render.test.js"
14
+ },
15
+ "license": "MIT",
16
+ "peerDependencies": {
17
+ "react": ">=18"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^25.9.2",
21
+ "@types/react": "^19.0.0",
22
+ "@types/react-dom": "^19.0.0",
23
+ "caelus": "*",
24
+ "react": "^19.0.0",
25
+ "react-dom": "^19.0.0",
26
+ "typescript": "^6.0.3"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/src/index.d.ts",
31
+ "default": "./dist/src/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/heavyblotto/caelus.git",
38
+ "directory": "packages/wheel"
39
+ },
40
+ "homepage": "https://ephemengine.com",
41
+ "keywords": [
42
+ "astrology",
43
+ "chart-wheel",
44
+ "react",
45
+ "svg",
46
+ "natal-chart"
47
+ ]
48
+ }