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 +42 -0
- package/dist/src/index.d.ts +66 -0
- package/dist/src/index.js +187 -0
- package/package.json +48 -0
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
|
+
}
|