@roxyapi/ui 0.2.3 → 0.3.1

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 (119) hide show
  1. package/AGENTS.md +216 -38
  2. package/README.md +200 -24
  3. package/dist/cdn/components/compatibility-card.js.map +1 -1
  4. package/dist/cdn/components/dasha-timeline.js +8 -8
  5. package/dist/cdn/components/dasha-timeline.js.map +2 -2
  6. package/dist/cdn/components/divisional-chart.js +35 -23
  7. package/dist/cdn/components/divisional-chart.js.map +4 -4
  8. package/dist/cdn/components/guna-milan.js.map +1 -1
  9. package/dist/cdn/components/kp-chart.js +306 -0
  10. package/dist/cdn/components/kp-chart.js.map +7 -0
  11. package/dist/cdn/components/kp-planets-table.js.map +1 -1
  12. package/dist/cdn/components/kp-ruling-planets.js +269 -0
  13. package/dist/cdn/components/kp-ruling-planets.js.map +7 -0
  14. package/dist/cdn/components/location-search.js +7 -5
  15. package/dist/cdn/components/location-search.js.map +3 -3
  16. package/dist/cdn/components/moon-phase.js.map +1 -1
  17. package/dist/cdn/components/nakshatra-card.js +229 -0
  18. package/dist/cdn/components/nakshatra-card.js.map +7 -0
  19. package/dist/cdn/components/natal-chart.js +228 -115
  20. package/dist/cdn/components/natal-chart.js.map +4 -4
  21. package/dist/cdn/components/numerology-card.js +3 -3
  22. package/dist/cdn/components/numerology-card.js.map +2 -2
  23. package/dist/cdn/components/panchang-table.js.map +1 -1
  24. package/dist/cdn/components/shadbala-table.js.map +1 -1
  25. package/dist/cdn/components/synastry-chart.js +3 -3
  26. package/dist/cdn/components/synastry-chart.js.map +2 -2
  27. package/dist/cdn/components/transits-table.js.map +1 -1
  28. package/dist/cdn/components/vedic-kundli.js +34 -22
  29. package/dist/cdn/components/vedic-kundli.js.map +4 -4
  30. package/dist/cdn/components/vedic-planets-table.js +231 -0
  31. package/dist/cdn/components/vedic-planets-table.js.map +7 -0
  32. package/dist/cdn/components/western-planets-table.js +220 -0
  33. package/dist/cdn/components/western-planets-table.js.map +7 -0
  34. package/dist/cdn/roxy-ui.js +1078 -331
  35. package/dist/cdn/roxy-ui.js.map +4 -4
  36. package/dist/components/compatibility-card.js.map +1 -1
  37. package/dist/components/dasha-timeline.d.ts.map +1 -1
  38. package/dist/components/dasha-timeline.js.map +2 -2
  39. package/dist/components/divisional-chart.d.ts +5 -3
  40. package/dist/components/divisional-chart.d.ts.map +1 -1
  41. package/dist/components/divisional-chart.js +159 -38
  42. package/dist/components/divisional-chart.js.map +3 -3
  43. package/dist/components/guna-milan.js.map +1 -1
  44. package/dist/components/kp-chart.d.ts +26 -0
  45. package/dist/components/kp-chart.d.ts.map +1 -0
  46. package/dist/components/kp-chart.js +382 -0
  47. package/dist/components/kp-chart.js.map +7 -0
  48. package/dist/components/kp-planets-table.js.map +1 -1
  49. package/dist/components/kp-ruling-planets.d.ts +20 -0
  50. package/dist/components/kp-ruling-planets.d.ts.map +1 -0
  51. package/dist/components/kp-ruling-planets.js +275 -0
  52. package/dist/components/kp-ruling-planets.js.map +7 -0
  53. package/dist/components/location-search.d.ts.map +1 -1
  54. package/dist/components/location-search.js +9 -2
  55. package/dist/components/location-search.js.map +2 -2
  56. package/dist/components/moon-phase.js.map +1 -1
  57. package/dist/components/nakshatra-card.d.ts +18 -0
  58. package/dist/components/nakshatra-card.d.ts.map +1 -0
  59. package/dist/components/nakshatra-card.js +231 -0
  60. package/dist/components/nakshatra-card.js.map +7 -0
  61. package/dist/components/natal-chart.d.ts +28 -0
  62. package/dist/components/natal-chart.d.ts.map +1 -1
  63. package/dist/components/natal-chart.js +401 -104
  64. package/dist/components/natal-chart.js.map +2 -2
  65. package/dist/components/numerology-card.d.ts.map +1 -1
  66. package/dist/components/numerology-card.js.map +2 -2
  67. package/dist/components/panchang-table.js.map +1 -1
  68. package/dist/components/shadbala-table.js.map +1 -1
  69. package/dist/components/synastry-chart.js.map +2 -2
  70. package/dist/components/transits-table.js.map +1 -1
  71. package/dist/components/vedic-kundli.d.ts +7 -3
  72. package/dist/components/vedic-kundli.d.ts.map +1 -1
  73. package/dist/components/vedic-kundli.js +209 -87
  74. package/dist/components/vedic-kundli.js.map +3 -3
  75. package/dist/components/vedic-planets-table.d.ts +21 -0
  76. package/dist/components/vedic-planets-table.d.ts.map +1 -0
  77. package/dist/components/vedic-planets-table.js +355 -0
  78. package/dist/components/vedic-planets-table.js.map +7 -0
  79. package/dist/components/western-planets-table.d.ts +21 -0
  80. package/dist/components/western-planets-table.d.ts.map +1 -0
  81. package/dist/components/western-planets-table.js +350 -0
  82. package/dist/components/western-planets-table.js.map +7 -0
  83. package/dist/index.cjs +2042 -695
  84. package/dist/index.cjs.map +4 -4
  85. package/dist/index.d.ts +5 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +2029 -682
  88. package/dist/index.js.map +4 -4
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.json +5 -0
  91. package/dist/styles/tokens.css +4 -0
  92. package/dist/types/types.gen.d.ts +343 -49
  93. package/dist/types/types.gen.d.ts.map +1 -1
  94. package/dist/utils/degree.d.ts +12 -0
  95. package/dist/utils/degree.d.ts.map +1 -1
  96. package/dist/utils/format.d.ts +1 -1
  97. package/dist/utils/kundli-render.d.ts +85 -12
  98. package/dist/utils/kundli-render.d.ts.map +1 -1
  99. package/dist/version.d.ts +1 -1
  100. package/package.json +1 -1
  101. package/src/components/dasha-timeline.ts +1 -7
  102. package/src/components/divisional-chart.ts +27 -41
  103. package/src/components/kp-chart.ts +313 -0
  104. package/src/components/kp-ruling-planets.ts +196 -0
  105. package/src/components/location-search.ts +16 -2
  106. package/src/components/nakshatra-card.ts +149 -0
  107. package/src/components/natal-chart.ts +408 -119
  108. package/src/components/numerology-card.ts +1 -5
  109. package/src/components/vedic-kundli.ts +30 -40
  110. package/src/components/vedic-planets-table.ts +184 -0
  111. package/src/components/western-planets-table.ts +180 -0
  112. package/src/index.ts +5 -0
  113. package/src/manifest.ts +146 -84
  114. package/src/styles/tokens.css +4 -0
  115. package/src/types/types.gen.ts +343 -49
  116. package/src/utils/degree.ts +21 -0
  117. package/src/utils/format.ts +1 -1
  118. package/src/utils/kundli-render.ts +234 -29
  119. package/src/version.ts +1 -1
@@ -1,9 +1,20 @@
1
1
  import { css, html, LitElement, nothing, svg } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
3
- import { PLANET_GLYPH, SIGN_GLYPH, SIGNS_ORDER } from '../tokens/index.js';
2
+ import { customElement, property, state } from 'lit/decorators.js';
3
+ import {
4
+ ASPECT_SYMBOL,
5
+ PLANET_GLYPH,
6
+ SIGN_GLYPH,
7
+ SIGNS_ORDER,
8
+ } from '../tokens/index.js';
4
9
  import type { NatalChartResponse } from '../types/index.js';
5
10
  import { baseStyles } from '../utils/base-styles.js';
6
- import { polarToCartesian } from '../utils/degree.js';
11
+ import {
12
+ arcMidpoint,
13
+ longitudeToSignPosition,
14
+ normalizeLongitude,
15
+ oppositePoint,
16
+ polarToCartesian,
17
+ } from '../utils/degree.js';
7
18
  import {
8
19
  ASPECT_CLASS,
9
20
  formatNumber,
@@ -76,12 +87,35 @@ export class RoxyNatalChart extends LitElement {
76
87
  font-family: var(--roxy-font-sans);
77
88
  }
78
89
 
90
+ .planet-deg {
91
+ fill: var(--roxy-fg, #0a0a0a);
92
+ font-size: 7px;
93
+ font-family: var(--roxy-font-sans);
94
+ }
95
+
96
+ .planet-deg .retro {
97
+ fill: var(--roxy-danger, #dc2626);
98
+ }
99
+
79
100
  .house-num {
80
101
  fill: var(--roxy-muted, #71717a);
81
102
  font-size: 9px;
82
103
  font-family: var(--roxy-font-sans);
83
104
  }
84
105
 
106
+ .cusp-deg {
107
+ fill: var(--roxy-muted, #71717a);
108
+ font-size: 6px;
109
+ font-family: var(--roxy-font-sans);
110
+ }
111
+
112
+ .tick {
113
+ stroke: var(--roxy-border, #e4e4e7);
114
+ }
115
+ .tick-major {
116
+ stroke: var(--roxy-secondary, #475569);
117
+ }
118
+
85
119
  .aspect {
86
120
  stroke-width: 0.8;
87
121
  fill: none;
@@ -131,6 +165,78 @@ export class RoxyNatalChart extends LitElement {
131
165
  vertical-align: middle;
132
166
  }
133
167
 
168
+ .tablist {
169
+ display: flex;
170
+ gap: 2px;
171
+ border-bottom: 2px solid var(--roxy-border, #e4e4e7);
172
+ }
173
+ .tab {
174
+ padding: var(--roxy-space-xs, 0.25rem) var(--roxy-space-md, 1rem);
175
+ font-size: var(--roxy-text-sm, 0.875rem);
176
+ background: none;
177
+ border: none;
178
+ border-bottom: 2px solid transparent;
179
+ margin-bottom: -2px;
180
+ cursor: pointer;
181
+ color: var(--roxy-muted, #71717a);
182
+ font-family: inherit;
183
+ transition: color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease);
184
+ }
185
+ .tab[aria-selected='true'] {
186
+ color: var(--roxy-accent-fg, #b45309);
187
+ border-bottom-color: var(--roxy-accent, #f59e0b);
188
+ font-weight: var(--roxy-weight-bold, 600);
189
+ }
190
+ .tab:hover:not([aria-selected='true']) {
191
+ color: var(--roxy-fg, #0a0a0a);
192
+ }
193
+
194
+ .grid-scroll {
195
+ overflow-x: auto;
196
+ -webkit-overflow-scrolling: touch;
197
+ }
198
+ table.aspect-grid {
199
+ border-collapse: collapse;
200
+ font-size: var(--roxy-text-xs, 0.75rem);
201
+ margin: 0 auto;
202
+ }
203
+ table.aspect-grid th,
204
+ table.aspect-grid td {
205
+ width: 1.6rem;
206
+ height: 1.6rem;
207
+ text-align: center;
208
+ border: 1px solid var(--roxy-border, #e4e4e7);
209
+ padding: 0;
210
+ }
211
+ table.aspect-grid th {
212
+ color: var(--roxy-secondary, #475569);
213
+ font-weight: var(--roxy-weight-bold, 600);
214
+ }
215
+ table.aspect-grid td.cell {
216
+ cursor: default;
217
+ }
218
+ table.aspect-grid td.empty {
219
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 18%, transparent);
220
+ }
221
+ table.aspect-grid td .asp {
222
+ font-size: 0.95em;
223
+ line-height: 1;
224
+ }
225
+ table.aspect-grid td.aspect-trine .asp,
226
+ table.aspect-grid td.aspect-sextile .asp {
227
+ color: var(--roxy-success, #16a34a);
228
+ }
229
+ table.aspect-grid td.aspect-square .asp,
230
+ table.aspect-grid td.aspect-opposition .asp {
231
+ color: var(--roxy-danger, #dc2626);
232
+ }
233
+ table.aspect-grid td.aspect-conjunction .asp {
234
+ color: var(--roxy-accent-fg, #b45309);
235
+ }
236
+ table.aspect-grid td.aspect-other .asp {
237
+ color: var(--roxy-muted, #71717a);
238
+ }
239
+
134
240
  .details {
135
241
  margin-top: var(--roxy-space-md, 1rem);
136
242
  }
@@ -171,48 +277,37 @@ export class RoxyNatalChart extends LitElement {
171
277
  margin: var(--roxy-space-md, 1rem) 0;
172
278
  }
173
279
 
174
- .dist-grid {
175
- display: grid;
176
- grid-template-columns: 1fr 1fr;
177
- gap: var(--roxy-space-md, 1rem);
280
+ .em-grid {
281
+ border-collapse: collapse;
282
+ font-size: var(--roxy-text-xs, 0.75rem);
283
+ width: 100%;
178
284
  }
179
-
180
- @container (max-width: 639px) {
181
- .dist-grid {
182
- grid-template-columns: 1fr;
183
- }
285
+ .em-grid th,
286
+ .em-grid td {
287
+ border: 1px solid var(--roxy-border, #e4e4e7);
288
+ padding: 3px 5px;
289
+ text-align: center;
290
+ vertical-align: middle;
184
291
  }
185
-
186
- .dist-section h3 {
187
- font-size: var(--roxy-text-xs, 0.75rem);
188
- font-weight: var(--roxy-weight-bold, 600);
292
+ .em-grid th {
189
293
  color: var(--roxy-muted, #71717a);
190
- margin: 0 0 var(--roxy-space-xs, 0.25rem);
294
+ font-weight: var(--roxy-weight-bold, 600);
191
295
  text-transform: uppercase;
192
- letter-spacing: 0.05em;
296
+ letter-spacing: 0.04em;
193
297
  }
194
-
195
- .dist-row {
196
- display: grid;
197
- grid-template-columns: 4rem 1fr 1.5rem;
198
- align-items: center;
199
- gap: var(--roxy-space-xs, 0.25rem);
200
- font-size: var(--roxy-text-xs, 0.75rem);
201
- color: var(--roxy-fg, #0f172a);
202
- margin-bottom: 4px;
298
+ .em-grid th[scope='row'] {
299
+ text-align: left;
203
300
  }
204
-
205
- .dist-bar {
206
- background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 20%, transparent);
207
- height: 6px;
208
- border-radius: 3px;
301
+ .em-grid td {
302
+ color: var(--roxy-accent, #f59e0b);
303
+ font-size: 0.95em;
304
+ line-height: 1.4;
305
+ min-width: 1.4rem;
209
306
  }
210
-
211
- .dist-bar > span {
212
- display: block;
213
- height: 100%;
214
- background: var(--roxy-accent, #f59e0b);
215
- border-radius: 3px;
307
+ .em-grid .em-total {
308
+ color: var(--roxy-fg, #0a0a0a);
309
+ font-weight: var(--roxy-weight-bold, 600);
310
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 25%, transparent);
216
311
  }
217
312
 
218
313
  .interpretations {
@@ -269,6 +364,10 @@ export class RoxyNatalChart extends LitElement {
269
364
  @property({ type: String, attribute: 'house-system', reflect: true })
270
365
  houseSystem: 'placidus' | 'whole-sign' | 'equal' | 'koch' = 'placidus';
271
366
 
367
+ /** Which view is showing: the wheel or the planet-by-planet aspect grid. */
368
+ @state()
369
+ private view: 'wheel' | 'grid' = 'wheel';
370
+
272
371
  private getPlanets(): PlanetEntry[] {
273
372
  return this.data?.planets ?? [];
274
373
  }
@@ -291,6 +390,7 @@ export class RoxyNatalChart extends LitElement {
291
390
  return html`<div class="roxy-empty" role="status">No chart data</div>`;
292
391
  const planets = this.getPlanets();
293
392
  const aspects = this.data.aspects ?? [];
393
+ const view = this.view;
294
394
 
295
395
  return html`<div class="wrap">
296
396
  <header>
@@ -305,44 +405,39 @@ export class RoxyNatalChart extends LitElement {
305
405
  : nothing
306
406
  }
307
407
  </header>
308
- <svg
309
- viewBox="0 0 ${SIZE} ${SIZE}"
310
- role="img"
311
- aria-label="Natal chart wheel with twelve houses, planets, and aspects"
408
+ <div
409
+ class="tablist"
410
+ role="tablist"
411
+ aria-label="Natal chart views"
412
+ @keydown=${this.onTabKeyDown}
312
413
  >
313
- <title>Natal chart wheel</title>
314
- <desc>
315
- Twelve zodiac sign segments around a circular wheel. Planet glyphs are
316
- placed at their ecliptic longitudes. Aspect lines connect related planets.
317
- </desc>
318
- <circle
319
- class="wheel-line"
320
- cx=${CENTER}
321
- cy=${CENTER}
322
- r=${OUTER_R}
323
- stroke-width="1.5"
324
- />
325
- <circle
326
- class="wheel-line"
327
- cx=${CENTER}
328
- cy=${CENTER}
329
- r=${HOUSE_R}
330
- stroke-width="1"
331
- />
332
- <circle
333
- class="wheel-line"
334
- cx=${CENTER}
335
- cy=${CENTER}
336
- r=${PLANET_R - 16}
337
- stroke-width="0.5"
338
- />
339
- ${this.renderSpokes()} ${this.renderSigns()} ${this.renderHouseNumbers()}
340
- ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)}
341
- ${this.renderAngles()}
342
- </svg>
414
+ ${(['wheel', 'grid'] as const).map(
415
+ (t) => html`<button
416
+ class="tab"
417
+ role="tab"
418
+ id="tab-${t}"
419
+ aria-selected=${view === t ? 'true' : 'false'}
420
+ aria-controls="panel-${t}"
421
+ tabindex=${view === t ? '0' : '-1'}
422
+ @click=${() => {
423
+ this.view = t;
424
+ }}
425
+ >
426
+ ${t === 'wheel' ? 'Wheel' : 'Aspect grid'}
427
+ </button>`,
428
+ )}
429
+ </div>
430
+ <div id="panel-${view}" role="tabpanel" aria-labelledby="tab-${view}">
431
+ ${view === 'wheel' ? this.renderWheel(planets, aspects) : this.renderAspectGrid(planets, aspects)}
432
+ </div>
343
433
  <div class="legend">
344
434
  <span>${planets.length} planets</span>
345
435
  <span>${aspects.length} aspects</span>
436
+ ${
437
+ this.data.houseSystem
438
+ ? html`<span>${this.data.houseSystem} houses</span>`
439
+ : nothing
440
+ }
346
441
  <span><span class="legend-swatch" style="background: var(--roxy-success)"></span>harmonious</span>
347
442
  <span><span class="legend-swatch" style="background: var(--roxy-danger)"></span>challenging</span>
348
443
  </div>
@@ -351,11 +446,115 @@ export class RoxyNatalChart extends LitElement {
351
446
  </div>`;
352
447
  }
353
448
 
449
+ private onTabKeyDown(e: KeyboardEvent) {
450
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
451
+ e.preventDefault();
452
+ this.view = this.view === 'wheel' ? 'grid' : 'wheel';
453
+ const next = this.view;
454
+ requestAnimationFrame(() => {
455
+ this.shadowRoot
456
+ ?.querySelector<HTMLButtonElement>(`#tab-${next}`)
457
+ ?.focus();
458
+ });
459
+ }
460
+
461
+ private renderWheel(planets: PlanetEntry[], aspects: AspectEntry[]) {
462
+ return html`<svg
463
+ viewBox="0 0 ${SIZE} ${SIZE}"
464
+ role="img"
465
+ aria-label="Natal chart wheel with twelve houses, planets, and aspects"
466
+ >
467
+ <title>Natal chart wheel</title>
468
+ <desc>
469
+ Twelve zodiac sign segments around a circular wheel. Planet glyphs are
470
+ placed at their ecliptic longitudes. Aspect lines connect related planets.
471
+ </desc>
472
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${OUTER_R} stroke-width="1.5" />
473
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${SIGN_R - 14} stroke-width="0.8" />
474
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${HOUSE_R} stroke-width="1" />
475
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${PLANET_R - 16} stroke-width="0.5" />
476
+ ${this.renderTicks()} ${this.renderSpokes()} ${this.renderSigns()}
477
+ ${this.renderHouseNumbers()} ${this.renderCuspDegrees()}
478
+ ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)}
479
+ ${this.renderAngles()}
480
+ </svg>`;
481
+ }
482
+
483
+ /**
484
+ * Planet-by-planet aspect grid: the lower-triangular matrix astrologers read
485
+ * alongside the wheel. Each filled cell shows the aspect glyph colored by
486
+ * nature, with the exact orb in the SVG-free `<title>` tooltip.
487
+ */
488
+ private renderAspectGrid(planets: PlanetEntry[], aspects: AspectEntry[]) {
489
+ const names = planets.map((p) => capitalize(p.name));
490
+ // Lookup aspects by unordered planet pair.
491
+ const byPair = new Map<string, AspectEntry>();
492
+ for (const a of aspects) {
493
+ const k = [capitalize(a.planet1), capitalize(a.planet2)].sort().join('|');
494
+ byPair.set(k, a);
495
+ }
496
+ if (names.length === 0)
497
+ return html`<p class="roxy-empty" role="status">No planets to grid</p>`;
498
+
499
+ return html`<div class="grid-scroll">
500
+ <table class="aspect-grid" aria-label="Planet by planet aspect grid">
501
+ <thead>
502
+ <tr>
503
+ <th></th>
504
+ ${names.slice(0, -1).map((n) => {
505
+ const g = PLANET_GLYPH[n] ?? n.slice(0, 2);
506
+ return html`<th scope="col" title=${n}>${g}</th>`;
507
+ })}
508
+ </tr>
509
+ </thead>
510
+ <tbody>
511
+ ${names.slice(1).map((rowName, ri) => {
512
+ const rowGlyph = PLANET_GLYPH[rowName] ?? rowName.slice(0, 2);
513
+ // Row i (1-based) pairs with columns 0..i-1.
514
+ return html`<tr>
515
+ <th scope="row" title=${rowName}>${rowGlyph}</th>
516
+ ${names.slice(0, ri + 1).map((colName) => {
517
+ const a = byPair.get([rowName, colName].sort().join('|'));
518
+ if (!a) return html`<td class="empty"></td>`;
519
+ const name = normalizeAspect(a);
520
+ const sym =
521
+ ASPECT_SYMBOL[name] ??
522
+ ASPECT_SYMBOL[name.replace(/-/g, '')] ??
523
+ name.slice(0, 3);
524
+ const cls = ASPECT_CLASS[name] ?? 'aspect-other';
525
+ const orb = formatNumber(a.orb, 1);
526
+ return html`<td class=${`cell ${cls}`} title=${`${rowName} ${name} ${colName}${orb ? ` (orb ${orb}°)` : ''}`}>
527
+ <span class="asp">${sym}</span>
528
+ </td>`;
529
+ })}
530
+ ${names.slice(ri + 1, -1).map(() => html`<td class="empty"></td>`)}
531
+ </tr>`;
532
+ })}
533
+ </tbody>
534
+ </table>
535
+ </div>`;
536
+ }
537
+
354
538
  private renderAngles() {
355
539
  const asc = this.getAscendant();
356
540
  const mc = this.getMidheaven();
357
- const items = [this.renderAngleMark(asc, 'ASC')];
358
- if (mc !== null) items.push(this.renderAngleMark(mc, 'MC'));
541
+ // ASC/DESC and MC/IC are exact axes; DESC and IC are the opposite points.
542
+ const items = [
543
+ this.renderAngleMark(asc, 'ASC'),
544
+ this.renderAngleMark(oppositePoint(asc), 'DSC'),
545
+ ];
546
+ if (mc !== null) {
547
+ items.push(this.renderAngleMark(mc, 'MC'));
548
+ items.push(this.renderAngleMark(oppositePoint(mc), 'IC'));
549
+ }
550
+ const pof = this.data?.partOfFortune?.longitude;
551
+ if (typeof pof === 'number') {
552
+ items.push(this.renderAngleMark(normalizeLongitude(pof), 'PoF'));
553
+ }
554
+ const vertex = this.data?.vertex?.longitude;
555
+ if (typeof vertex === 'number') {
556
+ items.push(this.renderAngleMark(normalizeLongitude(vertex), 'Vtx'));
557
+ }
359
558
  return items;
360
559
  }
361
560
 
@@ -373,8 +572,16 @@ export class RoxyNatalChart extends LitElement {
373
572
  }
374
573
 
375
574
  private renderSpokes() {
376
- return Array.from({ length: 12 }, (_, i) => {
377
- const angle = this.toAngle(i * 30);
575
+ // Draw a spoke at each real house cusp longitude so Placidus / Koch
576
+ // unequal houses render correctly. Fall back to 12 equal spokes from the
577
+ // Ascendant only when the response carries no houses array.
578
+ const houses = this.data?.houses ?? [];
579
+ const cuspLongitudes =
580
+ houses.length === 12
581
+ ? houses.map((h) => h.longitude)
582
+ : Array.from({ length: 12 }, (_, i) => this.getAscendant() + i * 30);
583
+ return cuspLongitudes.map((lon) => {
584
+ const angle = this.toAngle(lon);
378
585
  const start = polarToCartesian(CENTER, CENTER, HOUSE_R, angle);
379
586
  const end = polarToCartesian(CENTER, CENTER, OUTER_R, angle);
380
587
  return svg`<line class="wheel-line" x1=${start.x} y1=${start.y} x2=${end.x} y2=${end.y} stroke-width="0.8" />`;
@@ -390,6 +597,26 @@ export class RoxyNatalChart extends LitElement {
390
597
  }
391
598
 
392
599
  private renderHouseNumbers() {
600
+ const houses = this.data?.houses ?? [];
601
+ // Place each house number at the angular midpoint between its cusp and
602
+ // the next, so the label sits inside the house even when houses are
603
+ // unequal. Fall back to equal 30-degree sectors when houses are absent.
604
+ if (houses.length === 12) {
605
+ return houses.map((house, i) => {
606
+ const next = houses[(i + 1) % 12];
607
+ const mid = arcMidpoint(
608
+ house.longitude,
609
+ next ? next.longitude : house.longitude + 30,
610
+ );
611
+ const pos = polarToCartesian(
612
+ CENTER,
613
+ CENTER,
614
+ HOUSE_R - 12,
615
+ this.toAngle(mid),
616
+ );
617
+ return svg`<text class="house-num" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${house.number}</text>`;
618
+ });
619
+ }
393
620
  const ascSignIndex = Math.floor(this.getAscendant() / 30);
394
621
  return Array.from({ length: 12 }, (_, i) => {
395
622
  const angle = this.toAngle(i * 30 + 15);
@@ -399,15 +626,55 @@ export class RoxyNatalChart extends LitElement {
399
626
  });
400
627
  }
401
628
 
629
+ /**
630
+ * Degree ticks on the outer zodiac band: a short mark every 5 degrees and a
631
+ * longer one on each 30-degree sign cusp, so the wheel reads like a
632
+ * reference-grade chart rather than a bare ring of glyphs.
633
+ */
634
+ private renderTicks() {
635
+ const ticks = [];
636
+ for (let deg = 0; deg < 360; deg += 5) {
637
+ const angle = this.toAngle(deg);
638
+ const isMajor = deg % 30 === 0;
639
+ const inner = isMajor ? SIGN_R - 14 : OUTER_R - 5;
640
+ const a = polarToCartesian(CENTER, CENTER, inner, angle);
641
+ const b = polarToCartesian(CENTER, CENTER, OUTER_R, angle);
642
+ ticks.push(
643
+ svg`<line class=${isMajor ? 'tick tick-major' : 'tick'} x1=${a.x} y1=${a.y} x2=${b.x} y2=${b.y} stroke-width=${isMajor ? 1 : 0.5} />`,
644
+ );
645
+ }
646
+ return ticks;
647
+ }
648
+
649
+ /**
650
+ * Degree-and-minute label printed next to each house cusp on the wheel, so
651
+ * the exact cusp position is readable without leaving the chart.
652
+ */
653
+ private renderCuspDegrees() {
654
+ const houses = this.data?.houses ?? [];
655
+ if (houses.length !== 12) return nothing;
656
+ return houses.map((house) => {
657
+ const angle = this.toAngle(house.longitude);
658
+ const pos = polarToCartesian(CENTER, CENTER, HOUSE_R + 9, angle);
659
+ const sp = longitudeToSignPosition(house.longitude);
660
+ return svg`<text class="cusp-deg" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${sp.degree}°${String(sp.minute).padStart(2, '0')}'</text>`;
661
+ });
662
+ }
663
+
402
664
  private renderPlanets(planets: PlanetEntry[]) {
403
665
  return planets.map((p) => {
404
666
  if (!Number.isFinite(p.longitude)) return nothing;
405
667
  const angle = this.toAngle(p.longitude);
406
- const pos = polarToCartesian(CENTER, CENTER, PLANET_R, angle);
668
+ const glyphPos = polarToCartesian(CENTER, CENTER, PLANET_R, angle);
669
+ const degPos = polarToCartesian(CENTER, CENTER, PLANET_R - 13, angle);
407
670
  const glyph = PLANET_GLYPH[capitalize(p.name)] ?? p.name.slice(0, 2);
408
- const retro = p.isRetrograde ? ' R' : '';
409
- const display = retro ? `${glyph}ᴿ` : glyph;
410
- return svg`<text class="planet-glyph" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central"><title>${p.name}${retro}</title>${display}</text>`;
671
+ const sp = longitudeToSignPosition(p.longitude);
672
+ const retro = p.isRetrograde === true;
673
+ const degLabel = `${sp.degree}°${String(sp.minute).padStart(2, '0')}'`;
674
+ return svg`<g>
675
+ <text class="planet-glyph" x=${glyphPos.x} y=${glyphPos.y} text-anchor="middle" dominant-baseline="central"><title>${p.name}${retro ? ' retrograde' : ''} - ${degLabel} ${p.sign ?? ''}</title>${glyph}</text>
676
+ <text class="planet-deg" x=${degPos.x} y=${degPos.y} text-anchor="middle" dominant-baseline="central">${degLabel}${retro ? svg`<tspan class="retro"> ℞</tspan>` : nothing}</text>
677
+ </g>`;
411
678
  });
412
679
  }
413
680
 
@@ -417,10 +684,6 @@ export class RoxyNatalChart extends LitElement {
417
684
  if (!summary && !ai) return nothing;
418
685
 
419
686
  const retrogrades = summary?.retrogradePlanets ?? [];
420
- const elementDist = summary?.elementDistribution ?? {};
421
- const modalityDist = summary?.modalityDistribution ?? {};
422
- const elementMax = Math.max(1, ...Object.values(elementDist));
423
- const modalityMax = Math.max(1, ...Object.values(modalityDist));
424
687
 
425
688
  return html`<div class="details">
426
689
  ${
@@ -451,44 +714,70 @@ export class RoxyNatalChart extends LitElement {
451
714
  : nothing
452
715
  }
453
716
  ${ai?.summary ? html`<p class="summary">${ai.summary}</p>` : nothing}
454
- ${
455
- Object.keys(elementDist).length > 0 ||
456
- Object.keys(modalityDist).length > 0
457
- ? html`<div class="dist-grid">
458
- ${
459
- Object.keys(elementDist).length > 0
460
- ? html`<div class="dist-section">
461
- <h3>Elements</h3>
462
- ${Object.entries(elementDist).map(
463
- ([label, count]) => html`<div class="dist-row">
464
- <span>${label}</span>
465
- <div class="dist-bar"><span style="width: ${Math.round((count / elementMax) * 100)}%"></span></div>
466
- <span>${count}</span>
467
- </div>`,
468
- )}
469
- </div>`
470
- : nothing
471
- }
472
- ${
473
- Object.keys(modalityDist).length > 0
474
- ? html`<div class="dist-section">
475
- <h3>Modalities</h3>
476
- ${Object.entries(modalityDist).map(
477
- ([label, count]) => html`<div class="dist-row">
478
- <span>${label}</span>
479
- <div class="dist-bar"><span style="width: ${Math.round((count / modalityMax) * 100)}%"></span></div>
480
- <span>${count}</span>
481
- </div>`,
482
- )}
483
- </div>`
484
- : nothing
485
- }
486
- </div>`
487
- : nothing
488
- }
717
+ ${this.renderElementModalityGrid()}
489
718
  </div>`;
490
719
  }
491
720
 
721
+ /**
722
+ * Element by modality grid: the 4x3 cross-tab astrologers read for chart
723
+ * balance. Each planet is placed by its sign into one cell (Fire/Earth/Air/
724
+ * Water row, Cardinal/Fixed/Mutable column). Derived purely from the planet
725
+ * signs, with row, column, and grand totals.
726
+ */
727
+ private renderElementModalityGrid() {
728
+ const planets = this.getPlanets();
729
+ if (planets.length === 0) return nothing;
730
+ const ELEMENTS = ['Fire', 'Earth', 'Air', 'Water'] as const;
731
+ const MODALITIES = ['Cardinal', 'Fixed', 'Mutable'] as const;
732
+ const order = SIGNS_ORDER as readonly string[];
733
+
734
+ const cells: Record<string, Record<string, string[]>> = {};
735
+ for (const el of ELEMENTS)
736
+ cells[el] = { Cardinal: [], Fixed: [], Mutable: [] };
737
+ for (const p of planets) {
738
+ const idx = order.indexOf(capitalize(p.sign ?? ''));
739
+ if (idx < 0) continue;
740
+ const el = ELEMENTS[idx % 4];
741
+ const mod = MODALITIES[idx % 3];
742
+ const glyph =
743
+ PLANET_GLYPH[capitalize(p.name)] ?? capitalize(p.name).slice(0, 2);
744
+ cells[el]?.[mod]?.push(glyph);
745
+ }
746
+
747
+ return html`<table class="em-grid" aria-label="Element and modality distribution">
748
+ <thead>
749
+ <tr>
750
+ <th></th>
751
+ ${MODALITIES.map((m) => html`<th scope="col">${m.slice(0, 3)}</th>`)}
752
+ <th scope="col">Total</th>
753
+ </tr>
754
+ </thead>
755
+ <tbody>
756
+ ${ELEMENTS.map((el) => {
757
+ const rowTotal = MODALITIES.reduce(
758
+ (s, m) => s + (cells[el]?.[m]?.length ?? 0),
759
+ 0,
760
+ );
761
+ return html`<tr>
762
+ <th scope="row">${el}</th>
763
+ ${MODALITIES.map(
764
+ (m) => html`<td>${(cells[el]?.[m] ?? []).join(' ')}</td>`,
765
+ )}
766
+ <td class="em-total">${rowTotal}</td>
767
+ </tr>`;
768
+ })}
769
+ <tr>
770
+ <th scope="row">Total</th>
771
+ ${MODALITIES.map(
772
+ (m) =>
773
+ html`<td class="em-total">${ELEMENTS.reduce((s, el) => s + (cells[el]?.[m]?.length ?? 0), 0)}</td>`,
774
+ )}
775
+ <td class="em-total">${planets.length}</td>
776
+ </tr>
777
+ </tbody>
778
+ </table>`;
779
+ }
780
+
492
781
  private renderInterpretations() {
493
782
  const planets = this.getPlanets().filter((p) => p.interpretation);
494
783
  if (planets.length === 0) return nothing;
@@ -225,11 +225,7 @@ const LABELS: Record<string, string> = {
225
225
  chart: 'Numerology chart',
226
226
  };
227
227
 
228
- type KarmicDebtMeaning = {
229
- description: string;
230
- challenge: string;
231
- resolution: string;
232
- };
228
+ type KarmicDebtMeaning = CalculateLifePathResponse['karmicDebtMeaning'];
233
229
 
234
230
  function karmicDebtText(value: KarmicDebtMeaning | undefined): string {
235
231
  if (!value) return '';