@roxyapi/ui 0.2.2 → 0.3.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 (119) hide show
  1. package/AGENTS.md +15 -10
  2. package/README.md +18 -13
  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 +23 -18
  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
@@ -11,7 +11,7 @@ var __decorateClass = (decorators, target, key, kind) => {
11
11
 
12
12
  // packages/ui/src/components/natal-chart.ts
13
13
  import { css as css2, html, LitElement, nothing, svg } from "lit";
14
- import { customElement, property } from "lit/decorators.js";
14
+ import { customElement, property, state } from "lit/decorators.js";
15
15
 
16
16
  // packages/ui/src/tokens/index.ts
17
17
  var PLANET_GLYPH = {
@@ -69,6 +69,15 @@ var SIGNS_ORDER = [
69
69
  var RASHI_KEYS = SIGNS_ORDER.map(
70
70
  (s) => s.toLowerCase()
71
71
  );
72
+ var ASPECT_SYMBOL = {
73
+ conjunction: "\u260C",
74
+ opposition: "\u260D",
75
+ trine: "\u25B3",
76
+ square: "\u25A1",
77
+ sextile: "\u2731",
78
+ quincunx: "\u22BB",
79
+ semisextile: "\u22BC"
80
+ };
72
81
 
73
82
  // packages/ui/src/utils/base-styles.ts
74
83
  import { css } from "lit";
@@ -157,6 +166,35 @@ var baseStyles = css`
157
166
  `;
158
167
 
159
168
  // packages/ui/src/utils/degree.ts
169
+ function normalizeLongitude(lon) {
170
+ const wrapped = lon % 360;
171
+ return wrapped < 0 ? wrapped + 360 : wrapped;
172
+ }
173
+ function longitudeToSignPosition(longitude) {
174
+ const lon = normalizeLongitude(longitude);
175
+ const signIndex = Math.floor(lon / 30) % 12;
176
+ const within = lon % 30;
177
+ const degree = Math.floor(within);
178
+ const minuteFloat = (within - degree) * 60;
179
+ const minute = Math.floor(minuteFloat);
180
+ const second = Math.round((minuteFloat - minute) * 60);
181
+ return {
182
+ sign: SIGNS_ORDER[signIndex] ?? "Aries",
183
+ signIndex,
184
+ degree,
185
+ minute,
186
+ second
187
+ };
188
+ }
189
+ function oppositePoint(longitude) {
190
+ return normalizeLongitude(longitude + 180);
191
+ }
192
+ function arcMidpoint(start, end) {
193
+ const s = normalizeLongitude(start);
194
+ let span = normalizeLongitude(end) - s;
195
+ if (span < 0) span += 360;
196
+ return normalizeLongitude(s + span / 2);
197
+ }
160
198
  function polarToCartesian(cx, cy, radius, angleDeg) {
161
199
  const angleRad = angleDeg * Math.PI / 180;
162
200
  return {
@@ -201,6 +239,7 @@ var RoxyNatalChart = class extends LitElement {
201
239
  super(...arguments);
202
240
  this.data = null;
203
241
  this.houseSystem = "placidus";
242
+ this.view = "wheel";
204
243
  }
205
244
  getPlanets() {
206
245
  return this.data?.planets ?? [];
@@ -220,6 +259,7 @@ var RoxyNatalChart = class extends LitElement {
220
259
  return html`<div class="roxy-empty" role="status">No chart data</div>`;
221
260
  const planets = this.getPlanets();
222
261
  const aspects = this.data.aspects ?? [];
262
+ const view = this.view;
223
263
  return html`<div class="wrap">
224
264
  <header>
225
265
  <h2 class="title">Natal chart</h2>
@@ -227,44 +267,35 @@ var RoxyNatalChart = class extends LitElement {
227
267
  ${[this.data.birthDetails.date, this.data.birthDetails.time].filter(Boolean).join(" \xB7 ")}
228
268
  </div>` : nothing}
229
269
  </header>
230
- <svg
231
- viewBox="0 0 ${SIZE} ${SIZE}"
232
- role="img"
233
- aria-label="Natal chart wheel with twelve houses, planets, and aspects"
270
+ <div
271
+ class="tablist"
272
+ role="tablist"
273
+ aria-label="Natal chart views"
274
+ @keydown=${this.onTabKeyDown}
234
275
  >
235
- <title>Natal chart wheel</title>
236
- <desc>
237
- Twelve zodiac sign segments around a circular wheel. Planet glyphs are
238
- placed at their ecliptic longitudes. Aspect lines connect related planets.
239
- </desc>
240
- <circle
241
- class="wheel-line"
242
- cx=${CENTER}
243
- cy=${CENTER}
244
- r=${OUTER_R}
245
- stroke-width="1.5"
246
- />
247
- <circle
248
- class="wheel-line"
249
- cx=${CENTER}
250
- cy=${CENTER}
251
- r=${HOUSE_R}
252
- stroke-width="1"
253
- />
254
- <circle
255
- class="wheel-line"
256
- cx=${CENTER}
257
- cy=${CENTER}
258
- r=${PLANET_R - 16}
259
- stroke-width="0.5"
260
- />
261
- ${this.renderSpokes()} ${this.renderSigns()} ${this.renderHouseNumbers()}
262
- ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)}
263
- ${this.renderAngles()}
264
- </svg>
276
+ ${["wheel", "grid"].map(
277
+ (t) => html`<button
278
+ class="tab"
279
+ role="tab"
280
+ id="tab-${t}"
281
+ aria-selected=${view === t ? "true" : "false"}
282
+ aria-controls="panel-${t}"
283
+ tabindex=${view === t ? "0" : "-1"}
284
+ @click=${() => {
285
+ this.view = t;
286
+ }}
287
+ >
288
+ ${t === "wheel" ? "Wheel" : "Aspect grid"}
289
+ </button>`
290
+ )}
291
+ </div>
292
+ <div id="panel-${view}" role="tabpanel" aria-labelledby="tab-${view}">
293
+ ${view === "wheel" ? this.renderWheel(planets, aspects) : this.renderAspectGrid(planets, aspects)}
294
+ </div>
265
295
  <div class="legend">
266
296
  <span>${planets.length} planets</span>
267
297
  <span>${aspects.length} aspects</span>
298
+ ${this.data.houseSystem ? html`<span>${this.data.houseSystem} houses</span>` : nothing}
268
299
  <span><span class="legend-swatch" style="background: var(--roxy-success)"></span>harmonious</span>
269
300
  <span><span class="legend-swatch" style="background: var(--roxy-danger)"></span>challenging</span>
270
301
  </div>
@@ -272,11 +303,103 @@ var RoxyNatalChart = class extends LitElement {
272
303
  ${this.renderInterpretations()}
273
304
  </div>`;
274
305
  }
306
+ onTabKeyDown(e) {
307
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
308
+ e.preventDefault();
309
+ this.view = this.view === "wheel" ? "grid" : "wheel";
310
+ const next = this.view;
311
+ requestAnimationFrame(() => {
312
+ this.shadowRoot?.querySelector(`#tab-${next}`)?.focus();
313
+ });
314
+ }
315
+ renderWheel(planets, aspects) {
316
+ return html`<svg
317
+ viewBox="0 0 ${SIZE} ${SIZE}"
318
+ role="img"
319
+ aria-label="Natal chart wheel with twelve houses, planets, and aspects"
320
+ >
321
+ <title>Natal chart wheel</title>
322
+ <desc>
323
+ Twelve zodiac sign segments around a circular wheel. Planet glyphs are
324
+ placed at their ecliptic longitudes. Aspect lines connect related planets.
325
+ </desc>
326
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${OUTER_R} stroke-width="1.5" />
327
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${SIGN_R - 14} stroke-width="0.8" />
328
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${HOUSE_R} stroke-width="1" />
329
+ <circle class="wheel-line" cx=${CENTER} cy=${CENTER} r=${PLANET_R - 16} stroke-width="0.5" />
330
+ ${this.renderTicks()} ${this.renderSpokes()} ${this.renderSigns()}
331
+ ${this.renderHouseNumbers()} ${this.renderCuspDegrees()}
332
+ ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)}
333
+ ${this.renderAngles()}
334
+ </svg>`;
335
+ }
336
+ /**
337
+ * Planet-by-planet aspect grid: the lower-triangular matrix astrologers read
338
+ * alongside the wheel. Each filled cell shows the aspect glyph colored by
339
+ * nature, with the exact orb in the SVG-free `<title>` tooltip.
340
+ */
341
+ renderAspectGrid(planets, aspects) {
342
+ const names = planets.map((p) => capitalize(p.name));
343
+ const byPair = /* @__PURE__ */ new Map();
344
+ for (const a of aspects) {
345
+ const k = [capitalize(a.planet1), capitalize(a.planet2)].sort().join("|");
346
+ byPair.set(k, a);
347
+ }
348
+ if (names.length === 0)
349
+ return html`<p class="roxy-empty" role="status">No planets to grid</p>`;
350
+ return html`<div class="grid-scroll">
351
+ <table class="aspect-grid" aria-label="Planet by planet aspect grid">
352
+ <thead>
353
+ <tr>
354
+ <th></th>
355
+ ${names.slice(0, -1).map((n) => {
356
+ const g = PLANET_GLYPH[n] ?? n.slice(0, 2);
357
+ return html`<th scope="col" title=${n}>${g}</th>`;
358
+ })}
359
+ </tr>
360
+ </thead>
361
+ <tbody>
362
+ ${names.slice(1).map((rowName, ri) => {
363
+ const rowGlyph = PLANET_GLYPH[rowName] ?? rowName.slice(0, 2);
364
+ return html`<tr>
365
+ <th scope="row" title=${rowName}>${rowGlyph}</th>
366
+ ${names.slice(0, ri + 1).map((colName) => {
367
+ const a = byPair.get([rowName, colName].sort().join("|"));
368
+ if (!a) return html`<td class="empty"></td>`;
369
+ const name = normalizeAspect(a);
370
+ const sym = ASPECT_SYMBOL[name] ?? ASPECT_SYMBOL[name.replace(/-/g, "")] ?? name.slice(0, 3);
371
+ const cls = ASPECT_CLASS[name] ?? "aspect-other";
372
+ const orb = formatNumber(a.orb, 1);
373
+ return html`<td class=${`cell ${cls}`} title=${`${rowName} ${name} ${colName}${orb ? ` (orb ${orb}\xB0)` : ""}`}>
374
+ <span class="asp">${sym}</span>
375
+ </td>`;
376
+ })}
377
+ ${names.slice(ri + 1, -1).map(() => html`<td class="empty"></td>`)}
378
+ </tr>`;
379
+ })}
380
+ </tbody>
381
+ </table>
382
+ </div>`;
383
+ }
275
384
  renderAngles() {
276
385
  const asc = this.getAscendant();
277
386
  const mc = this.getMidheaven();
278
- const items = [this.renderAngleMark(asc, "ASC")];
279
- if (mc !== null) items.push(this.renderAngleMark(mc, "MC"));
387
+ const items = [
388
+ this.renderAngleMark(asc, "ASC"),
389
+ this.renderAngleMark(oppositePoint(asc), "DSC")
390
+ ];
391
+ if (mc !== null) {
392
+ items.push(this.renderAngleMark(mc, "MC"));
393
+ items.push(this.renderAngleMark(oppositePoint(mc), "IC"));
394
+ }
395
+ const pof = this.data?.partOfFortune?.longitude;
396
+ if (typeof pof === "number") {
397
+ items.push(this.renderAngleMark(normalizeLongitude(pof), "PoF"));
398
+ }
399
+ const vertex = this.data?.vertex?.longitude;
400
+ if (typeof vertex === "number") {
401
+ items.push(this.renderAngleMark(normalizeLongitude(vertex), "Vtx"));
402
+ }
280
403
  return items;
281
404
  }
282
405
  renderAngleMark(longitude, label) {
@@ -292,8 +415,10 @@ var RoxyNatalChart = class extends LitElement {
292
415
  `;
293
416
  }
294
417
  renderSpokes() {
295
- return Array.from({ length: 12 }, (_, i) => {
296
- const angle = this.toAngle(i * 30);
418
+ const houses = this.data?.houses ?? [];
419
+ const cuspLongitudes = houses.length === 12 ? houses.map((h) => h.longitude) : Array.from({ length: 12 }, (_, i) => this.getAscendant() + i * 30);
420
+ return cuspLongitudes.map((lon) => {
421
+ const angle = this.toAngle(lon);
297
422
  const start = polarToCartesian(CENTER, CENTER, HOUSE_R, angle);
298
423
  const end = polarToCartesian(CENTER, CENTER, OUTER_R, angle);
299
424
  return svg`<line class="wheel-line" x1=${start.x} y1=${start.y} x2=${end.x} y2=${end.y} stroke-width="0.8" />`;
@@ -307,6 +432,23 @@ var RoxyNatalChart = class extends LitElement {
307
432
  });
308
433
  }
309
434
  renderHouseNumbers() {
435
+ const houses = this.data?.houses ?? [];
436
+ if (houses.length === 12) {
437
+ return houses.map((house, i) => {
438
+ const next = houses[(i + 1) % 12];
439
+ const mid = arcMidpoint(
440
+ house.longitude,
441
+ next ? next.longitude : house.longitude + 30
442
+ );
443
+ const pos = polarToCartesian(
444
+ CENTER,
445
+ CENTER,
446
+ HOUSE_R - 12,
447
+ this.toAngle(mid)
448
+ );
449
+ return svg`<text class="house-num" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${house.number}</text>`;
450
+ });
451
+ }
310
452
  const ascSignIndex = Math.floor(this.getAscendant() / 30);
311
453
  return Array.from({ length: 12 }, (_, i) => {
312
454
  const angle = this.toAngle(i * 30 + 15);
@@ -315,15 +457,53 @@ var RoxyNatalChart = class extends LitElement {
315
457
  return svg`<text class="house-num" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${houseNum}</text>`;
316
458
  });
317
459
  }
460
+ /**
461
+ * Degree ticks on the outer zodiac band: a short mark every 5 degrees and a
462
+ * longer one on each 30-degree sign cusp, so the wheel reads like a
463
+ * reference-grade chart rather than a bare ring of glyphs.
464
+ */
465
+ renderTicks() {
466
+ const ticks = [];
467
+ for (let deg = 0; deg < 360; deg += 5) {
468
+ const angle = this.toAngle(deg);
469
+ const isMajor = deg % 30 === 0;
470
+ const inner = isMajor ? SIGN_R - 14 : OUTER_R - 5;
471
+ const a = polarToCartesian(CENTER, CENTER, inner, angle);
472
+ const b = polarToCartesian(CENTER, CENTER, OUTER_R, angle);
473
+ ticks.push(
474
+ 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} />`
475
+ );
476
+ }
477
+ return ticks;
478
+ }
479
+ /**
480
+ * Degree-and-minute label printed next to each house cusp on the wheel, so
481
+ * the exact cusp position is readable without leaving the chart.
482
+ */
483
+ renderCuspDegrees() {
484
+ const houses = this.data?.houses ?? [];
485
+ if (houses.length !== 12) return nothing;
486
+ return houses.map((house) => {
487
+ const angle = this.toAngle(house.longitude);
488
+ const pos = polarToCartesian(CENTER, CENTER, HOUSE_R + 9, angle);
489
+ const sp = longitudeToSignPosition(house.longitude);
490
+ 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>`;
491
+ });
492
+ }
318
493
  renderPlanets(planets) {
319
494
  return planets.map((p) => {
320
495
  if (!Number.isFinite(p.longitude)) return nothing;
321
496
  const angle = this.toAngle(p.longitude);
322
- const pos = polarToCartesian(CENTER, CENTER, PLANET_R, angle);
497
+ const glyphPos = polarToCartesian(CENTER, CENTER, PLANET_R, angle);
498
+ const degPos = polarToCartesian(CENTER, CENTER, PLANET_R - 13, angle);
323
499
  const glyph = PLANET_GLYPH[capitalize(p.name)] ?? p.name.slice(0, 2);
324
- const retro = p.isRetrograde ? " R" : "";
325
- const display = retro ? `${glyph}\u1D3F` : glyph;
326
- 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>`;
500
+ const sp = longitudeToSignPosition(p.longitude);
501
+ const retro = p.isRetrograde === true;
502
+ const degLabel = `${sp.degree}\xB0${String(sp.minute).padStart(2, "0")}'`;
503
+ return svg`<g>
504
+ <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>
505
+ <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>
506
+ </g>`;
327
507
  });
328
508
  }
329
509
  renderDetails() {
@@ -331,10 +511,6 @@ var RoxyNatalChart = class extends LitElement {
331
511
  const ai = this.data?.aspectsInterpretation;
332
512
  if (!summary && !ai) return nothing;
333
513
  const retrogrades = summary?.retrogradePlanets ?? [];
334
- const elementDist = summary?.elementDistribution ?? {};
335
- const modalityDist = summary?.modalityDistribution ?? {};
336
- const elementMax = Math.max(1, ...Object.values(elementDist));
337
- const modalityMax = Math.max(1, ...Object.values(modalityDist));
338
514
  return html`<div class="details">
339
515
  ${summary?.dominantElement || summary?.dominantModality ? html`<div class="pill-row">
340
516
  ${summary.dominantElement ? html`<span class="pill">Dominant element: ${summary.dominantElement}</span>` : nothing}
@@ -352,30 +528,64 @@ var RoxyNatalChart = class extends LitElement {
352
528
  })}
353
529
  </div>` : nothing}
354
530
  ${ai?.summary ? html`<p class="summary">${ai.summary}</p>` : nothing}
355
- ${Object.keys(elementDist).length > 0 || Object.keys(modalityDist).length > 0 ? html`<div class="dist-grid">
356
- ${Object.keys(elementDist).length > 0 ? html`<div class="dist-section">
357
- <h3>Elements</h3>
358
- ${Object.entries(elementDist).map(
359
- ([label, count]) => html`<div class="dist-row">
360
- <span>${label}</span>
361
- <div class="dist-bar"><span style="width: ${Math.round(count / elementMax * 100)}%"></span></div>
362
- <span>${count}</span>
363
- </div>`
364
- )}
365
- </div>` : nothing}
366
- ${Object.keys(modalityDist).length > 0 ? html`<div class="dist-section">
367
- <h3>Modalities</h3>
368
- ${Object.entries(modalityDist).map(
369
- ([label, count]) => html`<div class="dist-row">
370
- <span>${label}</span>
371
- <div class="dist-bar"><span style="width: ${Math.round(count / modalityMax * 100)}%"></span></div>
372
- <span>${count}</span>
373
- </div>`
374
- )}
375
- </div>` : nothing}
376
- </div>` : nothing}
531
+ ${this.renderElementModalityGrid()}
377
532
  </div>`;
378
533
  }
534
+ /**
535
+ * Element by modality grid: the 4x3 cross-tab astrologers read for chart
536
+ * balance. Each planet is placed by its sign into one cell (Fire/Earth/Air/
537
+ * Water row, Cardinal/Fixed/Mutable column). Derived purely from the planet
538
+ * signs, with row, column, and grand totals.
539
+ */
540
+ renderElementModalityGrid() {
541
+ const planets = this.getPlanets();
542
+ if (planets.length === 0) return nothing;
543
+ const ELEMENTS = ["Fire", "Earth", "Air", "Water"];
544
+ const MODALITIES = ["Cardinal", "Fixed", "Mutable"];
545
+ const order = SIGNS_ORDER;
546
+ const cells = {};
547
+ for (const el of ELEMENTS)
548
+ cells[el] = { Cardinal: [], Fixed: [], Mutable: [] };
549
+ for (const p of planets) {
550
+ const idx = order.indexOf(capitalize(p.sign ?? ""));
551
+ if (idx < 0) continue;
552
+ const el = ELEMENTS[idx % 4];
553
+ const mod = MODALITIES[idx % 3];
554
+ const glyph = PLANET_GLYPH[capitalize(p.name)] ?? capitalize(p.name).slice(0, 2);
555
+ cells[el]?.[mod]?.push(glyph);
556
+ }
557
+ return html`<table class="em-grid" aria-label="Element and modality distribution">
558
+ <thead>
559
+ <tr>
560
+ <th></th>
561
+ ${MODALITIES.map((m) => html`<th scope="col">${m.slice(0, 3)}</th>`)}
562
+ <th scope="col">Total</th>
563
+ </tr>
564
+ </thead>
565
+ <tbody>
566
+ ${ELEMENTS.map((el) => {
567
+ const rowTotal = MODALITIES.reduce(
568
+ (s, m) => s + (cells[el]?.[m]?.length ?? 0),
569
+ 0
570
+ );
571
+ return html`<tr>
572
+ <th scope="row">${el}</th>
573
+ ${MODALITIES.map(
574
+ (m) => html`<td>${(cells[el]?.[m] ?? []).join(" ")}</td>`
575
+ )}
576
+ <td class="em-total">${rowTotal}</td>
577
+ </tr>`;
578
+ })}
579
+ <tr>
580
+ <th scope="row">Total</th>
581
+ ${MODALITIES.map(
582
+ (m) => html`<td class="em-total">${ELEMENTS.reduce((s, el) => s + (cells[el]?.[m]?.length ?? 0), 0)}</td>`
583
+ )}
584
+ <td class="em-total">${planets.length}</td>
585
+ </tr>
586
+ </tbody>
587
+ </table>`;
588
+ }
379
589
  renderInterpretations() {
380
590
  const planets = this.getPlanets().filter((p) => p.interpretation);
381
591
  if (planets.length === 0) return nothing;
@@ -473,12 +683,35 @@ RoxyNatalChart.styles = [
473
683
  font-family: var(--roxy-font-sans);
474
684
  }
475
685
 
686
+ .planet-deg {
687
+ fill: var(--roxy-fg, #0a0a0a);
688
+ font-size: 7px;
689
+ font-family: var(--roxy-font-sans);
690
+ }
691
+
692
+ .planet-deg .retro {
693
+ fill: var(--roxy-danger, #dc2626);
694
+ }
695
+
476
696
  .house-num {
477
697
  fill: var(--roxy-muted, #71717a);
478
698
  font-size: 9px;
479
699
  font-family: var(--roxy-font-sans);
480
700
  }
481
701
 
702
+ .cusp-deg {
703
+ fill: var(--roxy-muted, #71717a);
704
+ font-size: 6px;
705
+ font-family: var(--roxy-font-sans);
706
+ }
707
+
708
+ .tick {
709
+ stroke: var(--roxy-border, #e4e4e7);
710
+ }
711
+ .tick-major {
712
+ stroke: var(--roxy-secondary, #475569);
713
+ }
714
+
482
715
  .aspect {
483
716
  stroke-width: 0.8;
484
717
  fill: none;
@@ -528,6 +761,78 @@ RoxyNatalChart.styles = [
528
761
  vertical-align: middle;
529
762
  }
530
763
 
764
+ .tablist {
765
+ display: flex;
766
+ gap: 2px;
767
+ border-bottom: 2px solid var(--roxy-border, #e4e4e7);
768
+ }
769
+ .tab {
770
+ padding: var(--roxy-space-xs, 0.25rem) var(--roxy-space-md, 1rem);
771
+ font-size: var(--roxy-text-sm, 0.875rem);
772
+ background: none;
773
+ border: none;
774
+ border-bottom: 2px solid transparent;
775
+ margin-bottom: -2px;
776
+ cursor: pointer;
777
+ color: var(--roxy-muted, #71717a);
778
+ font-family: inherit;
779
+ transition: color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, ease);
780
+ }
781
+ .tab[aria-selected='true'] {
782
+ color: var(--roxy-accent-fg, #b45309);
783
+ border-bottom-color: var(--roxy-accent, #f59e0b);
784
+ font-weight: var(--roxy-weight-bold, 600);
785
+ }
786
+ .tab:hover:not([aria-selected='true']) {
787
+ color: var(--roxy-fg, #0a0a0a);
788
+ }
789
+
790
+ .grid-scroll {
791
+ overflow-x: auto;
792
+ -webkit-overflow-scrolling: touch;
793
+ }
794
+ table.aspect-grid {
795
+ border-collapse: collapse;
796
+ font-size: var(--roxy-text-xs, 0.75rem);
797
+ margin: 0 auto;
798
+ }
799
+ table.aspect-grid th,
800
+ table.aspect-grid td {
801
+ width: 1.6rem;
802
+ height: 1.6rem;
803
+ text-align: center;
804
+ border: 1px solid var(--roxy-border, #e4e4e7);
805
+ padding: 0;
806
+ }
807
+ table.aspect-grid th {
808
+ color: var(--roxy-secondary, #475569);
809
+ font-weight: var(--roxy-weight-bold, 600);
810
+ }
811
+ table.aspect-grid td.cell {
812
+ cursor: default;
813
+ }
814
+ table.aspect-grid td.empty {
815
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 18%, transparent);
816
+ }
817
+ table.aspect-grid td .asp {
818
+ font-size: 0.95em;
819
+ line-height: 1;
820
+ }
821
+ table.aspect-grid td.aspect-trine .asp,
822
+ table.aspect-grid td.aspect-sextile .asp {
823
+ color: var(--roxy-success, #16a34a);
824
+ }
825
+ table.aspect-grid td.aspect-square .asp,
826
+ table.aspect-grid td.aspect-opposition .asp {
827
+ color: var(--roxy-danger, #dc2626);
828
+ }
829
+ table.aspect-grid td.aspect-conjunction .asp {
830
+ color: var(--roxy-accent-fg, #b45309);
831
+ }
832
+ table.aspect-grid td.aspect-other .asp {
833
+ color: var(--roxy-muted, #71717a);
834
+ }
835
+
531
836
  .details {
532
837
  margin-top: var(--roxy-space-md, 1rem);
533
838
  }
@@ -568,48 +873,37 @@ RoxyNatalChart.styles = [
568
873
  margin: var(--roxy-space-md, 1rem) 0;
569
874
  }
570
875
 
571
- .dist-grid {
572
- display: grid;
573
- grid-template-columns: 1fr 1fr;
574
- gap: var(--roxy-space-md, 1rem);
876
+ .em-grid {
877
+ border-collapse: collapse;
878
+ font-size: var(--roxy-text-xs, 0.75rem);
879
+ width: 100%;
575
880
  }
576
-
577
- @container (max-width: 639px) {
578
- .dist-grid {
579
- grid-template-columns: 1fr;
580
- }
881
+ .em-grid th,
882
+ .em-grid td {
883
+ border: 1px solid var(--roxy-border, #e4e4e7);
884
+ padding: 3px 5px;
885
+ text-align: center;
886
+ vertical-align: middle;
581
887
  }
582
-
583
- .dist-section h3 {
584
- font-size: var(--roxy-text-xs, 0.75rem);
585
- font-weight: var(--roxy-weight-bold, 600);
888
+ .em-grid th {
586
889
  color: var(--roxy-muted, #71717a);
587
- margin: 0 0 var(--roxy-space-xs, 0.25rem);
890
+ font-weight: var(--roxy-weight-bold, 600);
588
891
  text-transform: uppercase;
589
- letter-spacing: 0.05em;
892
+ letter-spacing: 0.04em;
590
893
  }
591
-
592
- .dist-row {
593
- display: grid;
594
- grid-template-columns: 4rem 1fr 1.5rem;
595
- align-items: center;
596
- gap: var(--roxy-space-xs, 0.25rem);
597
- font-size: var(--roxy-text-xs, 0.75rem);
598
- color: var(--roxy-fg, #0f172a);
599
- margin-bottom: 4px;
894
+ .em-grid th[scope='row'] {
895
+ text-align: left;
600
896
  }
601
-
602
- .dist-bar {
603
- background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 20%, transparent);
604
- height: 6px;
605
- border-radius: 3px;
897
+ .em-grid td {
898
+ color: var(--roxy-accent, #f59e0b);
899
+ font-size: 0.95em;
900
+ line-height: 1.4;
901
+ min-width: 1.4rem;
606
902
  }
607
-
608
- .dist-bar > span {
609
- display: block;
610
- height: 100%;
611
- background: var(--roxy-accent, #f59e0b);
612
- border-radius: 3px;
903
+ .em-grid .em-total {
904
+ color: var(--roxy-fg, #0a0a0a);
905
+ font-weight: var(--roxy-weight-bold, 600);
906
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 25%, transparent);
613
907
  }
614
908
 
615
909
  .interpretations {
@@ -665,6 +959,9 @@ __decorateClass([
665
959
  __decorateClass([
666
960
  property({ type: String, attribute: "house-system", reflect: true })
667
961
  ], RoxyNatalChart.prototype, "houseSystem", 2);
962
+ __decorateClass([
963
+ state()
964
+ ], RoxyNatalChart.prototype, "view", 2);
668
965
  RoxyNatalChart = __decorateClass([
669
966
  customElement("roxy-natal-chart")
670
967
  ], RoxyNatalChart);