@roxyapi/ui 0.7.0 → 0.8.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.
Files changed (40) hide show
  1. package/AGENTS.md +5 -1
  2. package/README.md +30 -1
  3. package/dist/cdn/components/bodygraph.js +54 -0
  4. package/dist/cdn/components/bodygraph.js.map +7 -0
  5. package/dist/cdn/components/forecast-timeline.js +45 -0
  6. package/dist/cdn/components/forecast-timeline.js.map +7 -0
  7. package/dist/cdn/roxy-ui.js +49 -40
  8. package/dist/cdn/roxy-ui.js.map +4 -4
  9. package/dist/components/bodygraph.d.ts +27 -0
  10. package/dist/components/bodygraph.d.ts.map +1 -0
  11. package/dist/components/bodygraph.js +11 -0
  12. package/dist/components/bodygraph.js.map +7 -0
  13. package/dist/components/forecast-timeline.d.ts +38 -0
  14. package/dist/components/forecast-timeline.d.ts.map +1 -0
  15. package/dist/components/forecast-timeline.js +2 -0
  16. package/dist/components/forecast-timeline.js.map +7 -0
  17. package/dist/index.cjs +45 -36
  18. package/dist/index.cjs.map +4 -4
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +52 -43
  22. package/dist/index.js.map +4 -4
  23. package/dist/manifest.d.ts.map +1 -1
  24. package/dist/manifest.json +2 -0
  25. package/dist/types/index.d.ts +1 -1
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/types.gen.d.ts +3994 -6
  28. package/dist/types/types.gen.d.ts.map +1 -1
  29. package/dist/utils/bodygraph-render.d.ts +105 -0
  30. package/dist/utils/bodygraph-render.d.ts.map +1 -0
  31. package/dist/version.d.ts +1 -1
  32. package/package.json +1 -1
  33. package/src/components/bodygraph.ts +390 -0
  34. package/src/components/forecast-timeline.ts +336 -0
  35. package/src/index.ts +4 -0
  36. package/src/manifest.ts +26 -0
  37. package/src/types/index.ts +1 -1
  38. package/src/types/types.gen.ts +4079 -6
  39. package/src/utils/bodygraph-render.ts +641 -0
  40. package/src/version.ts +1 -1
@@ -0,0 +1,641 @@
1
+ import type { TemplateResult } from 'lit';
2
+ import { nothing, svg } from 'lit';
3
+
4
+ /**
5
+ * Fixed geometry and renderer for the Human Design bodygraph. The diagram is a
6
+ * standard, invariant layout: nine centers in canonical positions and shapes,
7
+ * wired by 36 channels that each join two gates, with the 64 gates at fixed
8
+ * points on the center edges, all overlaid on a front-facing human silhouette so
9
+ * each center lands on the body part it governs. Only which centers are defined
10
+ * and which channels and gates are active changes per chart, so this module holds
11
+ * the geometry and the {@link RoxyBodygraph} component supplies the live state
12
+ * from the /human-design/bodygraph response.
13
+ *
14
+ * @remarks Every point is authored in one normalized 0 to 100 canonical grid (x
15
+ * left to right, y top to bottom) taken from the reference Jovian Archive
16
+ * bodygraph, then scaled into {@link BODYGRAPH_VIEWBOX} by a single transform so
17
+ * the chart and the body share one coordinate space and scale together. The grid
18
+ * is sized so the nine centers fill the figure exactly as in the canonical
19
+ * "nine centers on the human body" diagram: the Head center at the crown, Ajna at
20
+ * the forehead, Throat at the neck, G at the chest, Heart at the right chest,
21
+ * Spleen on the left torso, Solar Plexus on the right torso (its mirror), Sacral
22
+ * at the lower abdomen, and Root at the pelvis. Two structural truths the layout
23
+ * preserves: the Spleen (left) and the Solar Plexus (right) are mirror images at
24
+ * the Sacral height, so the chart is narrow at the top and wide at the bottom;
25
+ * and the Heart sits low and to the right of the G center, above the Solar
26
+ * Plexus. The reference chart spreads the two side centers to the page edge for
27
+ * channel clarity; here they are shifted inward by a fixed amount so they rest on
28
+ * the torso sides inside the figure, with every channel topology preserved. Center shapes follow the canonical orientations (Head triangle
29
+ * up, Ajna triangle down, Throat and Sacral and Root squares, G diamond, Heart
30
+ * triangle pointing right, Spleen triangle pointing right with its base on the
31
+ * far-left edge, Solar Plexus its mirror). The 36 channel gate pairs are the
32
+ * gold-standard set used by the RoxyAPI Human Design engine.
33
+ */
34
+
35
+ export type BodygraphCenterId =
36
+ | 'head'
37
+ | 'ajna'
38
+ | 'throat'
39
+ | 'g'
40
+ | 'heart'
41
+ | 'sacral'
42
+ | 'solar-plexus'
43
+ | 'spleen'
44
+ | 'root';
45
+
46
+ interface Point {
47
+ x: number;
48
+ y: number;
49
+ }
50
+
51
+ /**
52
+ * One center's drawable geometry: its semantic traditional color, the polygon
53
+ * point list of its canonical shape, and the anchor where its name label sits.
54
+ * Shapes are explicit point lists so the triangle and diamond orientations match
55
+ * the canonical diagram exactly. Label anchors sit in the empty margins outside
56
+ * each shape so they never collide with the gate numbers printed on the edges.
57
+ */
58
+ interface CenterGeometry {
59
+ id: BodygraphCenterId;
60
+ label: string;
61
+ color: CenterColor;
62
+ points: Point[];
63
+ labelAnchor: Point;
64
+ labelAlign: 'start' | 'middle' | 'end';
65
+ }
66
+
67
+ /**
68
+ * Traditional Human Design center color group. A defined center is filled with
69
+ * this semantic color (constant across light and dark, like chart data colors);
70
+ * an open center is left transparent with a thin theme-aware outline. The four
71
+ * groups mirror the canonical scheme: gold for the identity and inspiration
72
+ * centers (Head, G), green for the mental awareness center (Ajna), red for the
73
+ * life-force motors of will and vitality (Heart, Sacral), brown for the
74
+ * pressure, expression, and remaining awareness centers (Throat, Spleen, Solar
75
+ * Plexus, Root).
76
+ */
77
+ export type CenterColor = 'gold' | 'green' | 'red' | 'brown';
78
+
79
+ /**
80
+ * Viewport mapping from the normalized 0 to 100 grid into the SVG viewBox. The
81
+ * chart proper occupies the inner grid; {@link PAD} is the slim margin (in grid
82
+ * units) the body silhouette extends past the centers on every side, so the head
83
+ * rises just above the Head center, the hips sit just below Root, and the
84
+ * shoulders spread just outside Spleen and Solar Plexus. UNIT scales grid units
85
+ * to viewBox units; the chart keeps its natural narrow-top, wide-bottom shape
86
+ * because x and y share one scale.
87
+ */
88
+ const UNIT = 4;
89
+ const PAD = 9;
90
+ const VIEW_W = (100 + 2 * PAD) * UNIT;
91
+ const VIEW_H = (100 + 2 * PAD) * UNIT;
92
+
93
+ /** Map a normalized grid point (0 to 100) into viewBox units. */
94
+ function g(x: number, y: number): Point {
95
+ return { x: (x + PAD) * UNIT, y: (y + PAD) * UNIT };
96
+ }
97
+
98
+ /** Chart horizontal center, the axis of symmetry for the body and the side centers. */
99
+ const AXIS = 50;
100
+
101
+ /** Reflect a normalized grid x across {@link AXIS}, the vertical axis of symmetry. */
102
+ const mirrorX = (x: number): number => 2 * AXIS - x;
103
+
104
+ /**
105
+ * Gate positions authored per center in the normalized 0 to 100 grid, before the
106
+ * mapping through {@link g}. Symmetry is structural, not hand-typed: every center
107
+ * on the central column is authored balanced about {@link AXIS}; the Spleen is
108
+ * authored once on the left torso and the Solar Plexus is derived as its exact
109
+ * mirror in {@link buildGatePoints}, so the two side centers can never drift out
110
+ * of alignment. The Heart is the one center the canonical bodygraph places off
111
+ * the axis (low and to the right of the G center, with no left counterpart).
112
+ * Within each center the gates follow the canonical reading order so the numbers
113
+ * print where a printed chart shows them.
114
+ *
115
+ * @remarks Solar Plexus is intentionally empty here and filled by reflecting
116
+ * Spleen; {@link SPLEEN_TO_SOLAR_PLEXUS} pairs each Spleen gate with the Solar
117
+ * Plexus gate at its mirror position (base-top to base-top, apex to apex).
118
+ */
119
+ const GATES_BY_CENTER: Record<
120
+ BodygraphCenterId,
121
+ Record<number, [number, number]>
122
+ > = {
123
+ head: { 64: [45.5, 11.2], 61: [50, 11.2], 63: [54.5, 11.2] },
124
+ ajna: {
125
+ 47: [45.3, 18.0],
126
+ 24: [50, 18.0],
127
+ 4: [54.7, 18.0],
128
+ 17: [45.6, 20.6],
129
+ 11: [54.4, 20.6],
130
+ 43: [50, 25.3],
131
+ },
132
+ throat: {
133
+ 62: [45.5, 32.3],
134
+ 23: [50, 32.3],
135
+ 56: [54.5, 32.3],
136
+ 16: [42, 34.6],
137
+ 35: [58, 34.6],
138
+ 12: [58, 37.6],
139
+ 20: [42, 40.6],
140
+ 45: [58, 40.6],
141
+ 31: [46, 42.4],
142
+ 8: [50, 42.4],
143
+ 33: [54, 42.4],
144
+ },
145
+ g: {
146
+ 1: [50, 47.5],
147
+ 7: [45.6, 50.3],
148
+ 13: [54.4, 50.3],
149
+ 10: [40, 53.3],
150
+ 25: [60, 53.3],
151
+ 15: [45.6, 56.6],
152
+ 46: [54.4, 56.6],
153
+ 2: [50, 59.0],
154
+ },
155
+ heart: {
156
+ 21: [62.5, 58.5],
157
+ 51: [62.5, 61.3],
158
+ 26: [62.5, 64.1],
159
+ 40: [73, 61.3],
160
+ },
161
+ spleen: {
162
+ 48: [20, 70.6],
163
+ 57: [24, 72.3],
164
+ 44: [28, 74.0],
165
+ 50: [32, 75.6],
166
+ 32: [28, 77.2],
167
+ 28: [24, 78.9],
168
+ 18: [20, 80.6],
169
+ },
170
+ sacral: {
171
+ 5: [45.5, 72.5],
172
+ 14: [50, 72.5],
173
+ 29: [54.5, 72.5],
174
+ 34: [42, 75.6],
175
+ 27: [42, 79.0],
176
+ 59: [58, 79.0],
177
+ 42: [45.5, 81.4],
178
+ 3: [50, 81.4],
179
+ 9: [54.5, 81.4],
180
+ },
181
+ 'solar-plexus': {},
182
+ root: {
183
+ 53: [45.5, 89.9],
184
+ 60: [50, 89.9],
185
+ 52: [54.5, 89.9],
186
+ 54: [42, 92.7],
187
+ 19: [58, 92.7],
188
+ 38: [42, 95.3],
189
+ 39: [58, 95.3],
190
+ 58: [42, 98.0],
191
+ 41: [58, 98.0],
192
+ },
193
+ };
194
+
195
+ /** Spleen gate to the Solar Plexus gate at its mirror position (base-top, ..., apex, ..., base-bottom). */
196
+ const SPLEEN_TO_SOLAR_PLEXUS: Record<number, number> = {
197
+ 48: 36,
198
+ 57: 22,
199
+ 44: 37,
200
+ 50: 6,
201
+ 32: 49,
202
+ 28: 55,
203
+ 18: 30,
204
+ };
205
+
206
+ /**
207
+ * Assemble the viewBox-space gate anchors and the gate to center index from
208
+ * {@link GATES_BY_CENTER}, filling the Solar Plexus by reflecting the Spleen so
209
+ * the two side centers are guaranteed mirror images. Each gate sits on the
210
+ * canonical edge of its center where its channels connect, so channel lines join
211
+ * gate to gate cleanly and the activated numbers print in their traditional spots.
212
+ */
213
+ function buildGatePoints(): {
214
+ points: Record<number, Point>;
215
+ centerOf: Record<number, BodygraphCenterId>;
216
+ } {
217
+ const points: Record<number, Point> = {};
218
+ const centerOf: Record<number, BodygraphCenterId> = {};
219
+ for (const [spleenGate, [x, y]] of Object.entries(GATES_BY_CENTER.spleen)) {
220
+ GATES_BY_CENTER['solar-plexus'][
221
+ SPLEEN_TO_SOLAR_PLEXUS[Number(spleenGate)]
222
+ ] = [mirrorX(x), y];
223
+ }
224
+ for (const [center, gates] of Object.entries(GATES_BY_CENTER) as Array<
225
+ [BodygraphCenterId, Record<number, [number, number]>]
226
+ >) {
227
+ for (const [gate, [x, y]] of Object.entries(gates)) {
228
+ points[Number(gate)] = g(x, y);
229
+ centerOf[Number(gate)] = center;
230
+ }
231
+ }
232
+ return { points, centerOf };
233
+ }
234
+
235
+ /**
236
+ * The viewBox-space gate anchors ({@link GATE_POINTS}) and the gate to center
237
+ * index ({@link GATE_CENTER}). Exported so the geometry tests can assert the
238
+ * layout invariants (side-center mirror symmetry, central-column balance, gates
239
+ * inside their centers) without rendering.
240
+ */
241
+ export const { points: GATE_POINTS, centerOf: GATE_CENTER } = buildGatePoints();
242
+
243
+ /** Horizontal axis of symmetry in viewBox units, the reflection axis for geometry tests. */
244
+ export const CHART_AXIS_X = VIEW_W / 2;
245
+
246
+ /** Build a polygon from normalized grid corner pairs, mapping each through {@link g}. */
247
+ function shape(corners: ReadonlyArray<readonly [number, number]>): Point[] {
248
+ return corners.map(([x, y]) => g(x, y));
249
+ }
250
+
251
+ /** A square center, centered on {@link AXIS}, spanning the given half-width and y range. */
252
+ function squareShape(halfWidth: number, top: number, bottom: number): Point[] {
253
+ return shape([
254
+ [AXIS - halfWidth, top],
255
+ [AXIS + halfWidth, top],
256
+ [AXIS + halfWidth, bottom],
257
+ [AXIS - halfWidth, bottom],
258
+ ]);
259
+ }
260
+
261
+ /** The Spleen triangle (base on the far-left edge, apex pointing right toward center). */
262
+ const SPLEEN_SHAPE: ReadonlyArray<readonly [number, number]> = [
263
+ [18.4, 68.0],
264
+ [18.4, 81.8],
265
+ [34.7, 74.9],
266
+ ];
267
+
268
+ /**
269
+ * Center shapes in canonical orientation and color, labels anchored in the
270
+ * margins. Central-column centers are built centered on {@link AXIS}; the Solar
271
+ * Plexus shape is the Spleen reflected across the axis, so the side centers stay
272
+ * exact mirrors. The Heart is the deliberate off-axis exception.
273
+ */
274
+ export const CENTER_GEOMETRY: readonly CenterGeometry[] = [
275
+ {
276
+ id: 'head',
277
+ label: 'Head',
278
+ color: 'gold',
279
+ points: shape([
280
+ [40.0, 14.3],
281
+ [60.0, 14.3],
282
+ [50.0, 6.0],
283
+ ]),
284
+ labelAnchor: g(63, 9),
285
+ labelAlign: 'start',
286
+ },
287
+ {
288
+ id: 'ajna',
289
+ label: 'Ajna',
290
+ color: 'green',
291
+ points: shape([
292
+ [40.0, 15.6],
293
+ [60.0, 15.6],
294
+ [50.0, 27.6],
295
+ ]),
296
+ labelAnchor: g(62, 21),
297
+ labelAlign: 'start',
298
+ },
299
+ {
300
+ id: 'throat',
301
+ label: 'Throat',
302
+ color: 'brown',
303
+ points: squareShape(9.5, 30.4, 43.6),
304
+ labelAnchor: g(83, 34),
305
+ labelAlign: 'start',
306
+ },
307
+ {
308
+ id: 'g',
309
+ label: 'G',
310
+ color: 'gold',
311
+ points: shape([
312
+ [50.0, 45.0],
313
+ [60.7, 53.3],
314
+ [50.0, 61.6],
315
+ [39.3, 53.3],
316
+ ]),
317
+ labelAnchor: g(13, 51),
318
+ labelAlign: 'end',
319
+ },
320
+ {
321
+ id: 'heart',
322
+ label: 'Heart',
323
+ color: 'red',
324
+ points: shape([
325
+ [61.5, 57.0],
326
+ [76.5, 61.3],
327
+ [61.5, 65.6],
328
+ ]),
329
+ labelAnchor: g(85, 56),
330
+ labelAlign: 'start',
331
+ },
332
+ {
333
+ id: 'spleen',
334
+ label: 'Spleen',
335
+ color: 'brown',
336
+ points: shape(SPLEEN_SHAPE),
337
+ labelAnchor: g(13, 70),
338
+ labelAlign: 'end',
339
+ },
340
+ {
341
+ id: 'sacral',
342
+ label: 'Sacral',
343
+ color: 'red',
344
+ points: squareShape(9.5, 70.6, 83.6),
345
+ // Lower-right, below the Solar Plexus: a left-side leader would clip the
346
+ // Spleen, which sits at the same height as the Sacral.
347
+ labelAnchor: g(85, 88),
348
+ labelAlign: 'start',
349
+ },
350
+ {
351
+ id: 'solar-plexus',
352
+ label: 'Solar Plexus',
353
+ color: 'brown',
354
+ points: shape(
355
+ SPLEEN_SHAPE.map(([x, y]) => [mirrorX(x), y] as [number, number]),
356
+ ),
357
+ labelAnchor: g(87, 73),
358
+ labelAlign: 'start',
359
+ },
360
+ {
361
+ id: 'root',
362
+ label: 'Root',
363
+ color: 'brown',
364
+ points: squareShape(9.5, 87.9, 99.9),
365
+ labelAnchor: g(50, 103),
366
+ labelAlign: 'middle',
367
+ },
368
+ ];
369
+
370
+ /**
371
+ * The 36 channels as ordered gate pairs. This is the canonical Human Design
372
+ * channel set; a channel is active only when both of its gates are activated,
373
+ * which the live response reports in its `channels` array. The static list lets
374
+ * the renderer draw every channel as a hanging (inactive) line and overlay the
375
+ * active ones, so an open bodygraph still shows its full wiring skeleton.
376
+ */
377
+ export const CHANNEL_PAIRS: ReadonlyArray<readonly [number, number]> = [
378
+ [64, 47],
379
+ [61, 24],
380
+ [63, 4],
381
+ [17, 62],
382
+ [11, 56],
383
+ [43, 23],
384
+ [16, 48],
385
+ [20, 34],
386
+ [20, 10],
387
+ [7, 31],
388
+ [1, 8],
389
+ [13, 33],
390
+ [21, 45],
391
+ [12, 22],
392
+ [35, 36],
393
+ [57, 20],
394
+ [15, 5],
395
+ [2, 14],
396
+ [46, 29],
397
+ [34, 10],
398
+ [10, 57],
399
+ [25, 51],
400
+ [27, 50],
401
+ [57, 34],
402
+ [26, 44],
403
+ [18, 58],
404
+ [28, 38],
405
+ [32, 54],
406
+ [3, 60],
407
+ [9, 52],
408
+ [42, 53],
409
+ [59, 6],
410
+ [19, 49],
411
+ [39, 55],
412
+ [41, 30],
413
+ [37, 40],
414
+ ];
415
+
416
+ /**
417
+ * Front-facing standing figure behind the chart, mirror-symmetric about
418
+ * {@link AXIS}. Authored in the same normalized grid as the centers, so it scales
419
+ * with the chart and frames it as in the canonical "nine centers on the human
420
+ * body" diagram: a rounded head holding the Head center, a short neck at the
421
+ * Throat, shoulders that slope to arms hanging down the outside of the torso so
422
+ * their span frames the Spleen (left) and Solar Plexus (right), a torso that
423
+ * narrows at the waist below the chest, then hips ending at the pelvis just below
424
+ * the Root center. The outline is one closed loop: crown, temple, jaw, neck,
425
+ * shoulder, down the outer arm to the hand beside the hip, in under the hip to
426
+ * the pelvis hem, mirrored back up the left side. The right half is built from
427
+ * cubic beziers and reflected so the figure is exactly symmetric. Drawn first and
428
+ * behind everything so the centers, wiring, and gate numbers stay legible on top.
429
+ */
430
+ const BODY_PATH = buildBodyPath();
431
+
432
+ function buildBodyPath(): string {
433
+ const m = mirrorX;
434
+ // Right-side outline in grid units (x, y) from the crown down to the
435
+ // pelvis-right corner: a start point, then triples of (ctrl1, ctrl2, end). A
436
+ // rounded head hugging the Head and Ajna centers, a narrowed neck, shoulders at
437
+ // their widest, the outer torso running just past the Spleen and Solar Plexus
438
+ // (right edges near x 82 at y 74), then waist and hip to a flat pelvis hem. The
439
+ // left side is the mirror and the hem is a straight line, so the figure reads as
440
+ // a torso, not a point.
441
+ const r: Array<[number, number]> = [
442
+ [50, -2], // crown apex (start)
443
+ [60, -2], // crown round (ctrl)
444
+ [60.5, 9], // head side (ctrl)
445
+ [57, 18], // brow, head holds Head + Ajna (end)
446
+ [56, 21], // cheek (ctrl)
447
+ [54, 24], // jaw (ctrl)
448
+ [52, 27], // neck right, narrowed (end)
449
+ [54, 28], // neck base (ctrl)
450
+ [64, 30], // trapezius slope (ctrl)
451
+ [80, 34], // shoulder / deltoid, the widest point (end)
452
+ [83.5, 40], // upper torso side (ctrl)
453
+ [84, 56], // outer torso, frames the side centers (ctrl)
454
+ [83, 74], // torso side past Spleen / Solar Plexus (end)
455
+ [82, 84], // waist (ctrl)
456
+ [76, 92], // hip (ctrl)
457
+ [68, 97], // hip (end)
458
+ [64, 99], // toward the pelvis (ctrl)
459
+ [62, 100], // pelvis (ctrl)
460
+ [60, 100], // pelvis-right corner (end)
461
+ ];
462
+ const segs: string[] = [`M ${pt(r[0])}`];
463
+ // Walk the right side as cubic beziers, three points per segment.
464
+ for (let i = 1; i + 2 < r.length; i += 3) {
465
+ segs.push(`C ${pt(r[i])} ${pt(r[i + 1])} ${pt(r[i + 2])}`);
466
+ }
467
+ // Flat pelvis hem across to the mirrored corner.
468
+ segs.push(`L ${ptm(r[r.length - 1], m)}`);
469
+ // Mirror the right walk back up the left side to the crown.
470
+ for (let i = r.length - 3; i >= 1; i -= 3) {
471
+ segs.push(`C ${ptm(r[i + 1], m)} ${ptm(r[i], m)} ${ptm(r[i - 1], m)}`);
472
+ }
473
+ segs.push('Z');
474
+ return segs.join(' ');
475
+ }
476
+
477
+ function pt([x, y]: [number, number]): string {
478
+ const p = g(x, y);
479
+ return `${p.x} ${p.y}`;
480
+ }
481
+
482
+ function ptm([x, y]: [number, number], m: (x: number) => number): string {
483
+ const p = g(m(x), y);
484
+ return `${p.x} ${p.y}`;
485
+ }
486
+
487
+ function polygonPoints(pts: Point[]): string {
488
+ return pts.map((p) => `${p.x},${p.y}`).join(' ');
489
+ }
490
+
491
+ function pairKey(a: number, b: number): string {
492
+ return a < b ? `${a}-${b}` : `${b}-${a}`;
493
+ }
494
+
495
+ /** Render the body silhouette behind the chart. */
496
+ function renderBody(): TemplateResult {
497
+ return svg`<path class="bg-body" d=${BODY_PATH} />`;
498
+ }
499
+
500
+ /**
501
+ * Render every channel as a single line joining its two gates, so the wiring
502
+ * always reads as a connected diagram rather than stubs poking out of centers.
503
+ * Each of the 36 channels draws a faint full-length connector; a channel with
504
+ * both gates active is redrawn thick and solid (a defined channel); a channel
505
+ * with only one gate active lights that gate's half toward the midpoint over the
506
+ * connector (a hanging gate). This mirrors how a printed bodygraph colors a full
507
+ * channel only when both gates are active and shows a single hanging gate
508
+ * otherwise, while keeping every connection visible.
509
+ */
510
+ function renderChannels(
511
+ activeChannels: Set<string>,
512
+ activeGates: Set<number>,
513
+ ): TemplateResult[] {
514
+ const lines: TemplateResult[] = [];
515
+ for (const [a, b] of CHANNEL_PAIRS) {
516
+ const pa = GATE_POINTS[a];
517
+ const pb = GATE_POINTS[b];
518
+ if (!pa || !pb) continue;
519
+ lines.push(
520
+ svg`<line class="bg-channel" x1=${pa.x} y1=${pa.y} x2=${pb.x} y2=${pb.y} />`,
521
+ );
522
+ if (activeChannels.has(pairKey(a, b))) {
523
+ lines.push(
524
+ svg`<line class="bg-channel on" x1=${pa.x} y1=${pa.y} x2=${pb.x} y2=${pb.y} />`,
525
+ );
526
+ continue;
527
+ }
528
+ const mid = { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 };
529
+ if (activeGates.has(a)) {
530
+ lines.push(
531
+ svg`<line class="bg-half" x1=${pa.x} y1=${pa.y} x2=${mid.x} y2=${mid.y} />`,
532
+ );
533
+ }
534
+ if (activeGates.has(b)) {
535
+ lines.push(
536
+ svg`<line class="bg-half" x1=${pb.x} y1=${pb.y} x2=${mid.x} y2=${mid.y} />`,
537
+ );
538
+ }
539
+ }
540
+ return lines;
541
+ }
542
+
543
+ /** Closest point to `p` on segment `a`-`b`, clamped to the segment ends. */
544
+ function closestPointOnSegment(p: Point, a: Point, b: Point): Point {
545
+ const dx = b.x - a.x;
546
+ const dy = b.y - a.y;
547
+ const len2 = dx * dx + dy * dy;
548
+ if (len2 === 0) return a;
549
+ const t = Math.max(
550
+ 0,
551
+ Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2),
552
+ );
553
+ return { x: a.x + t * dx, y: a.y + t * dy };
554
+ }
555
+
556
+ /** Closest point on a closed polygon's perimeter to `p`, where a label leader should land. */
557
+ function closestPointOnPolygon(p: Point, poly: readonly Point[]): Point {
558
+ let best = poly[0];
559
+ let bestDist = Number.POSITIVE_INFINITY;
560
+ for (let i = 0; i < poly.length; i++) {
561
+ const q = closestPointOnSegment(p, poly[i], poly[(i + 1) % poly.length]);
562
+ const d = (q.x - p.x) ** 2 + (q.y - p.y) ** 2;
563
+ if (d < bestDist) {
564
+ bestDist = d;
565
+ best = q;
566
+ }
567
+ }
568
+ return best;
569
+ }
570
+
571
+ /**
572
+ * Render the nine center shapes, filled with their semantic color when defined
573
+ * and outlined when open, each with a margin label tied to its shape by a thin
574
+ * leader line so the Heart and every other center is unambiguous regardless of
575
+ * whether it is defined.
576
+ */
577
+ function renderCenters(defined: Set<BodygraphCenterId>): TemplateResult[] {
578
+ return CENTER_GEOMETRY.map((c) => {
579
+ const isDefined = defined.has(c.id);
580
+ const cls = `bg-center bg-${c.color}${isDefined ? ' defined' : ''}`;
581
+ const edge = closestPointOnPolygon(c.labelAnchor, c.points);
582
+ return svg`<g>
583
+ <line class="bg-leader" x1=${c.labelAnchor.x} y1=${c.labelAnchor.y} x2=${edge.x} y2=${edge.y} />
584
+ <polygon class=${cls} points=${polygonPoints(c.points)}><title>${c.label}: ${isDefined ? 'defined' : 'open'}</title></polygon>
585
+ <text class="bg-center-label" x=${c.labelAnchor.x} y=${c.labelAnchor.y} text-anchor=${c.labelAlign} dominant-baseline="central">${c.label}</text>
586
+ </g>`;
587
+ });
588
+ }
589
+
590
+ /**
591
+ * Render the activated gate numbers at their fixed points. Numbers sit on top of
592
+ * the filled centers, so a halo (a wider, background-colored stroke under the
593
+ * fill) keeps them legible against any center color in both themes.
594
+ */
595
+ function renderGateNumbers(
596
+ activeGates: Set<number>,
597
+ titles: Map<number, string>,
598
+ ): TemplateResult[] {
599
+ const out: TemplateResult[] = [];
600
+ for (const [gate, p] of Object.entries(GATE_POINTS)) {
601
+ const num = Number(gate);
602
+ if (!activeGates.has(num)) continue;
603
+ const title = titles.get(num);
604
+ out.push(
605
+ svg`<text class="bg-gate" x=${p.x} y=${p.y} text-anchor="middle" dominant-baseline="central">${num}${title ? svg`<title>${title}</title>` : nothing}</text>`,
606
+ );
607
+ }
608
+ return out;
609
+ }
610
+
611
+ export interface BodygraphRenderInput {
612
+ definedCenters: Set<BodygraphCenterId>;
613
+ activeChannels: Set<string>;
614
+ activeGates: Set<number>;
615
+ gateTitles: Map<number, string>;
616
+ }
617
+
618
+ /** Build the lookup key for an active channel from its two gate numbers. */
619
+ export function channelKey(a: number, b: number): string {
620
+ return pairKey(a, b);
621
+ }
622
+
623
+ export const BODYGRAPH_VIEWBOX = `0 0 ${VIEW_W} ${VIEW_H}`;
624
+
625
+ /**
626
+ * Render the full bodygraph SVG inner content for the given live state. The
627
+ * caller wraps it in an `<svg>` with {@link BODYGRAPH_VIEWBOX} and applies its
628
+ * own theming CSS. Draw order: body silhouette under channels under centers
629
+ * under gate numbers, so the body is the backdrop, the wiring sits behind the
630
+ * shapes, and the numbers stay legible on top.
631
+ */
632
+ export function renderBodygraphSvg(
633
+ input: BodygraphRenderInput,
634
+ ): TemplateResult {
635
+ return svg`
636
+ ${renderBody()}
637
+ ${renderChannels(input.activeChannels, input.activeGates)}
638
+ ${renderCenters(input.definedCenters)}
639
+ ${renderGateNumbers(input.activeGates, input.gateTitles)}
640
+ `;
641
+ }
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/sync-version.ts. Do not edit.
2
- export const ROXY_UI_VERSION = '0.7.0';
2
+ export const ROXY_UI_VERSION = '0.8.0';