@roxyapi/ui 0.11.0 → 0.12.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 (142) hide show
  1. package/AGENTS.md +6 -0
  2. package/README.md +7 -1
  3. package/components-catalog.json +111 -1
  4. package/dist/cdn/components/astrocartography-map.js +58 -0
  5. package/dist/cdn/components/astrocartography-map.js.map +7 -0
  6. package/dist/cdn/components/divisional-chart.js +7 -7
  7. package/dist/cdn/components/divisional-chart.js.map +1 -1
  8. package/dist/cdn/components/dosha-card.js +2 -2
  9. package/dist/cdn/components/dosha-card.js.map +3 -3
  10. package/dist/cdn/components/fixed-stars.js +52 -0
  11. package/dist/cdn/components/fixed-stars.js.map +7 -0
  12. package/dist/cdn/components/hd-variables.js +2 -2
  13. package/dist/cdn/components/hd-variables.js.map +3 -3
  14. package/dist/cdn/components/hexagram.js +3 -3
  15. package/dist/cdn/components/hexagram.js.map +3 -3
  16. package/dist/cdn/components/local-space-compass.js +58 -0
  17. package/dist/cdn/components/local-space-compass.js.map +7 -0
  18. package/dist/cdn/components/moon-phase.js +3 -3
  19. package/dist/cdn/components/moon-phase.js.map +3 -3
  20. package/dist/cdn/components/natal-chart.js +8 -8
  21. package/dist/cdn/components/natal-chart.js.map +2 -2
  22. package/dist/cdn/components/positions-table.js +52 -0
  23. package/dist/cdn/components/positions-table.js.map +7 -0
  24. package/dist/cdn/components/profection-card.js +52 -0
  25. package/dist/cdn/components/profection-card.js.map +7 -0
  26. package/dist/cdn/components/reference-card.js +3 -3
  27. package/dist/cdn/components/reference-card.js.map +3 -3
  28. package/dist/cdn/components/relocation-wheel.js +61 -0
  29. package/dist/cdn/components/relocation-wheel.js.map +7 -0
  30. package/dist/cdn/components/synastry-chart.js +4 -4
  31. package/dist/cdn/components/synastry-chart.js.map +2 -2
  32. package/dist/cdn/components/vedic-kundli.js +5 -5
  33. package/dist/cdn/components/vedic-kundli.js.map +1 -1
  34. package/dist/cdn/components/vedic-planets-table.js +2 -2
  35. package/dist/cdn/components/vedic-planets-table.js.map +1 -1
  36. package/dist/cdn/components/western-planets-table.js +2 -2
  37. package/dist/cdn/components/western-planets-table.js.map +1 -1
  38. package/dist/cdn/components/yoga-list.js +3 -3
  39. package/dist/cdn/components/yoga-list.js.map +3 -3
  40. package/dist/cdn/roxy-ui.js +84 -72
  41. package/dist/cdn/roxy-ui.js.map +4 -4
  42. package/dist/components/astrocartography-map.d.ts +27 -0
  43. package/dist/components/astrocartography-map.d.ts.map +1 -0
  44. package/dist/components/astrocartography-map.js +8 -0
  45. package/dist/components/astrocartography-map.js.map +7 -0
  46. package/dist/components/divisional-chart.js +22 -22
  47. package/dist/components/divisional-chart.js.map +1 -1
  48. package/dist/components/dosha-card.d.ts.map +1 -1
  49. package/dist/components/dosha-card.js +1 -1
  50. package/dist/components/dosha-card.js.map +3 -3
  51. package/dist/components/fixed-stars.d.ts +21 -0
  52. package/dist/components/fixed-stars.d.ts.map +1 -0
  53. package/dist/components/fixed-stars.js +2 -0
  54. package/dist/components/fixed-stars.js.map +7 -0
  55. package/dist/components/hd-variables.d.ts.map +1 -1
  56. package/dist/components/hd-variables.js +1 -1
  57. package/dist/components/hd-variables.js.map +3 -3
  58. package/dist/components/hexagram.d.ts +3 -1
  59. package/dist/components/hexagram.d.ts.map +1 -1
  60. package/dist/components/hexagram.js +1 -1
  61. package/dist/components/hexagram.js.map +3 -3
  62. package/dist/components/local-space-compass.d.ts +23 -0
  63. package/dist/components/local-space-compass.d.ts.map +1 -0
  64. package/dist/components/local-space-compass.js +8 -0
  65. package/dist/components/local-space-compass.js.map +7 -0
  66. package/dist/components/moon-phase.d.ts.map +1 -1
  67. package/dist/components/moon-phase.js +1 -1
  68. package/dist/components/moon-phase.js.map +3 -3
  69. package/dist/components/natal-chart.d.ts +2 -0
  70. package/dist/components/natal-chart.d.ts.map +1 -1
  71. package/dist/components/natal-chart.js +6 -6
  72. package/dist/components/natal-chart.js.map +2 -2
  73. package/dist/components/positions-table.d.ts +34 -0
  74. package/dist/components/positions-table.d.ts.map +1 -0
  75. package/dist/components/positions-table.js +2 -0
  76. package/dist/components/positions-table.js.map +7 -0
  77. package/dist/components/profection-card.d.ts +18 -0
  78. package/dist/components/profection-card.d.ts.map +1 -0
  79. package/dist/components/profection-card.js +2 -0
  80. package/dist/components/profection-card.js.map +7 -0
  81. package/dist/components/reference-card.d.ts.map +1 -1
  82. package/dist/components/reference-card.js +1 -1
  83. package/dist/components/reference-card.js.map +3 -3
  84. package/dist/components/relocation-wheel.d.ts +21 -0
  85. package/dist/components/relocation-wheel.d.ts.map +1 -0
  86. package/dist/components/relocation-wheel.js +11 -0
  87. package/dist/components/relocation-wheel.js.map +7 -0
  88. package/dist/components/synastry-chart.js +3 -3
  89. package/dist/components/synastry-chart.js.map +2 -2
  90. package/dist/components/vedic-kundli.js +14 -14
  91. package/dist/components/vedic-kundli.js.map +1 -1
  92. package/dist/components/vedic-planets-table.js +1 -1
  93. package/dist/components/vedic-planets-table.js.map +1 -1
  94. package/dist/components/western-planets-table.js +1 -1
  95. package/dist/components/western-planets-table.js.map +1 -1
  96. package/dist/components/yoga-list.d.ts +5 -2
  97. package/dist/components/yoga-list.d.ts.map +1 -1
  98. package/dist/components/yoga-list.js +1 -1
  99. package/dist/components/yoga-list.js.map +3 -3
  100. package/dist/generated/endpoint-bindings.d.ts.map +1 -1
  101. package/dist/index.cjs +55 -43
  102. package/dist/index.cjs.map +4 -4
  103. package/dist/index.d.ts +6 -0
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +63 -51
  106. package/dist/index.js.map +4 -4
  107. package/dist/manifest.d.ts.map +1 -1
  108. package/dist/manifest.json +6 -0
  109. package/dist/types/index.d.ts +1 -1
  110. package/dist/types/index.d.ts.map +1 -1
  111. package/dist/types/types.gen.d.ts +7864 -5381
  112. package/dist/types/types.gen.d.ts.map +1 -1
  113. package/dist/utils/degree.d.ts +2 -0
  114. package/dist/utils/degree.d.ts.map +1 -1
  115. package/dist/utils/planet-color.d.ts +3 -0
  116. package/dist/utils/planet-color.d.ts.map +1 -0
  117. package/dist/utils/world-map.d.ts +8 -0
  118. package/dist/utils/world-map.d.ts.map +1 -0
  119. package/dist/version.d.ts +1 -1
  120. package/package.json +2 -1
  121. package/src/components/astrocartography-map.ts +442 -0
  122. package/src/components/dosha-card.ts +48 -16
  123. package/src/components/fixed-stars.ts +254 -0
  124. package/src/components/hd-variables.ts +30 -2
  125. package/src/components/hexagram.ts +11 -11
  126. package/src/components/local-space-compass.ts +299 -0
  127. package/src/components/moon-phase.ts +21 -2
  128. package/src/components/natal-chart.ts +36 -24
  129. package/src/components/positions-table.ts +442 -0
  130. package/src/components/profection-card.ts +173 -0
  131. package/src/components/reference-card.ts +40 -8
  132. package/src/components/relocation-wheel.ts +170 -0
  133. package/src/components/yoga-list.ts +95 -2
  134. package/src/generated/endpoint-bindings.ts +62 -0
  135. package/src/index.ts +6 -0
  136. package/src/manifest.ts +79 -0
  137. package/src/types/index.ts +1 -1
  138. package/src/types/types.gen.ts +7814 -5263
  139. package/src/utils/degree.ts +11 -0
  140. package/src/utils/planet-color.ts +45 -0
  141. package/src/utils/world-map.ts +8 -0
  142. package/src/version.ts +1 -1
@@ -0,0 +1,254 @@
1
+ import { css, html, nothing } from 'lit';
2
+ import { customElement } from 'lit/decorators.js';
3
+ import { SIGN_GLYPH } from '../tokens/index.js';
4
+ import type { FixedStarsResponse } from '../types/index.js';
5
+ import { RoxyDataElement } from '../utils/base-element.js';
6
+ import { baseStyles } from '../utils/base-styles.js';
7
+ import { formatDegreeInSign } from '../utils/degree.js';
8
+ import { chevron, disclosureStyles } from '../utils/disclosure.js';
9
+ import { formatNumber } from '../utils/format.js';
10
+ import { capitalize } from '../utils/string.js';
11
+
12
+ type Star = FixedStarsResponse['stars'][number];
13
+
14
+ /**
15
+ * Fixed stars table. Leads with the high-value view from a
16
+ * /astrology/fixed-stars response: every star-to-natal-point conjunction sorted
17
+ * tightest first, each with its reading. The full precessed star catalog
18
+ * (position, magnitude, traditional nature, keywords) sits in a secondary
19
+ * disclosure so the contacts stay front and center.
20
+ */
21
+ @customElement('roxy-fixed-stars')
22
+ export class RoxyFixedStars extends RoxyDataElement<FixedStarsResponse> {
23
+ static styles = [
24
+ baseStyles,
25
+ disclosureStyles,
26
+ css`
27
+ .wrap {
28
+ width: 100%;
29
+ background: var(--roxy-surface, #fff);
30
+ color: var(--roxy-fg, #0a0a0a);
31
+ border: 1px solid var(--roxy-border, #e4e4e7);
32
+ border-radius: var(--roxy-radius-md, 8px);
33
+ padding: var(--roxy-space-lg, 1.5rem);
34
+ box-shadow: var(--roxy-shadow-sm);
35
+ display: grid;
36
+ gap: var(--roxy-space-md, 1rem);
37
+ }
38
+ header {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: baseline;
42
+ gap: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem);
43
+ }
44
+ .title {
45
+ font-size: var(--roxy-text-lg, 1.125rem);
46
+ font-weight: var(--roxy-weight-bold, 600);
47
+ margin: 0;
48
+ color: var(--roxy-primary, #0f172a);
49
+ }
50
+ .badge {
51
+ padding: 2px 8px;
52
+ border-radius: var(--roxy-radius-full, 9999px);
53
+ font-size: var(--roxy-text-xs, 0.75rem);
54
+ background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 14%, transparent);
55
+ color: var(--roxy-fg, #0a0a0a);
56
+ }
57
+ .badge b {
58
+ color: var(--roxy-accent-ink, #b45309);
59
+ font-weight: 600;
60
+ }
61
+ .summary {
62
+ color: var(--roxy-fg, #0a0a0a);
63
+ font-size: var(--roxy-text-sm, 0.875rem);
64
+ margin: 0;
65
+ }
66
+ .empty-note {
67
+ color: var(--roxy-muted, #71717a);
68
+ font-size: var(--roxy-text-sm, 0.875rem);
69
+ margin: 0;
70
+ }
71
+ .subhead {
72
+ font-size: var(--roxy-text-sm, 0.875rem);
73
+ font-weight: 600;
74
+ color: var(--roxy-muted, #71717a);
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.06em;
77
+ margin: 0 0 var(--roxy-space-sm, 0.5rem);
78
+ }
79
+ .interp-card {
80
+ border: 1px solid var(--roxy-border, #e4e4e7);
81
+ border-radius: var(--roxy-radius-md, 8px);
82
+ padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem);
83
+ margin-bottom: var(--roxy-space-xs, 0.25rem);
84
+ }
85
+ .interp-card summary {
86
+ cursor: pointer;
87
+ font-weight: 500;
88
+ color: var(--roxy-fg, #0a0a0a);
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ gap: var(--roxy-space-md, 1rem);
93
+ }
94
+ .contact {
95
+ display: inline-flex;
96
+ align-items: baseline;
97
+ gap: 0.4rem;
98
+ }
99
+ .contact .point {
100
+ color: var(--roxy-accent-ink, #b45309);
101
+ font-weight: 600;
102
+ }
103
+ .interp-aside {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 0.5rem;
107
+ }
108
+ .interp-aside small {
109
+ color: var(--roxy-muted, #71717a);
110
+ font-weight: 400;
111
+ font-variant-numeric: tabular-nums;
112
+ }
113
+ .interp-body {
114
+ margin-top: var(--roxy-space-xs, 0.25rem);
115
+ color: var(--roxy-fg, #0a0a0a);
116
+ font-size: var(--roxy-text-sm, 0.875rem);
117
+ }
118
+ .catalog summary {
119
+ cursor: pointer;
120
+ font-weight: 600;
121
+ color: var(--roxy-fg, #0a0a0a);
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 0.5rem;
125
+ }
126
+ .scroll {
127
+ overflow-x: auto;
128
+ margin-top: var(--roxy-space-sm, 0.5rem);
129
+ }
130
+ table {
131
+ width: 100%;
132
+ border-collapse: collapse;
133
+ font-size: var(--roxy-text-sm, 0.875rem);
134
+ }
135
+ th,
136
+ td {
137
+ text-align: left;
138
+ padding: 6px 10px;
139
+ border-bottom: 1px solid var(--roxy-border, #e4e4e7);
140
+ white-space: nowrap;
141
+ vertical-align: top;
142
+ }
143
+ th {
144
+ color: var(--roxy-muted, #71717a);
145
+ font-weight: 600;
146
+ text-transform: uppercase;
147
+ letter-spacing: 0.04em;
148
+ font-size: var(--roxy-text-xs, 0.75rem);
149
+ }
150
+ td.num {
151
+ text-align: right;
152
+ font-variant-numeric: tabular-nums;
153
+ }
154
+ .sg {
155
+ color: var(--roxy-secondary, #475569);
156
+ margin-right: 0.3rem;
157
+ }
158
+ .kw {
159
+ display: flex;
160
+ flex-wrap: wrap;
161
+ gap: 0.2rem;
162
+ white-space: normal;
163
+ max-width: 18rem;
164
+ }
165
+ .kw span {
166
+ padding: 0 6px;
167
+ border-radius: var(--roxy-radius-full, 9999px);
168
+ font-size: var(--roxy-text-xs, 0.75rem);
169
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 45%, transparent);
170
+ color: var(--roxy-fg, #0a0a0a);
171
+ }
172
+ `,
173
+ ];
174
+
175
+ protected renderEmpty() {
176
+ return html`<div class="roxy-empty" role="status">No fixed star data</div>`;
177
+ }
178
+
179
+ protected renderData(data: FixedStarsResponse) {
180
+ const conjunctions = data.conjunctions ?? [];
181
+ const stars = data.stars ?? [];
182
+ return html`<div class="wrap">
183
+ <header>
184
+ <h2 class="title">Fixed stars</h2>
185
+ ${
186
+ typeof data.orb === 'number'
187
+ ? html`<span class="badge"><b>Orb</b> ${formatNumber(data.orb, 1)}°</span>`
188
+ : nothing
189
+ }
190
+ </header>
191
+ ${data.summary ? html`<p class="summary">${data.summary}</p>` : nothing}
192
+ ${
193
+ conjunctions.length
194
+ ? html`<section>
195
+ <p class="subhead">Conjunctions to the chart</p>
196
+ ${conjunctions.map((c, i) => {
197
+ return html`<details class="interp-card" name="fixed-star-contacts" ?open=${i === 0}>
198
+ <summary>
199
+ <span class="contact"><span class="point">${c.point}</span> conjunct ${c.star}</span>
200
+ <span class="interp-aside">
201
+ <small>orb ${formatNumber(c.orb, 2)}°</small>
202
+ ${chevron()}
203
+ </span>
204
+ </summary>
205
+ ${c.interpretation ? html`<div class="interp-body">${c.interpretation}</div>` : nothing}
206
+ </details>`;
207
+ })}
208
+ </section>`
209
+ : html`<p class="empty-note">No star sits within the orb of a natal point.</p>`
210
+ }
211
+ ${stars.length ? this.renderCatalog(stars) : nothing}
212
+ </div>`;
213
+ }
214
+
215
+ private renderCatalog(stars: Star[]) {
216
+ return html`<details class="catalog">
217
+ <summary>${chevron()} Star catalog (${stars.length})</summary>
218
+ <div class="scroll">
219
+ <table>
220
+ <caption class="subhead">Precessed positions for the chart date</caption>
221
+ <thead>
222
+ <tr>
223
+ <th scope="col">Star</th>
224
+ <th scope="col">Position</th>
225
+ <th scope="col" class="num">Mag</th>
226
+ <th scope="col">Nature</th>
227
+ <th scope="col">Keywords</th>
228
+ </tr>
229
+ </thead>
230
+ <tbody>
231
+ ${stars.map((s) => {
232
+ const g = SIGN_GLYPH[capitalize(s.sign)];
233
+ return html`<tr>
234
+ <td>${s.name}</td>
235
+ <td>${g ? html`<span class="sg">${g}</span>` : nothing}${formatDegreeInSign(s.degree)} ${s.sign}</td>
236
+ <td class="num">${formatNumber(s.magnitude, 1)}</td>
237
+ <td>${s.nature}</td>
238
+ <td>
239
+ <div class="kw">${(s.keywords ?? []).map((k) => html`<span>${k}</span>`)}</div>
240
+ </td>
241
+ </tr>`;
242
+ })}
243
+ </tbody>
244
+ </table>
245
+ </div>
246
+ </details>`;
247
+ }
248
+ }
249
+
250
+ declare global {
251
+ interface HTMLElementTagNameMap {
252
+ 'roxy-fixed-stars': RoxyFixedStars;
253
+ }
254
+ }
@@ -80,7 +80,14 @@ export class RoxyHdVariables extends RoxyDataElement<CalculateVariablesResponse>
80
80
  ];
81
81
 
82
82
  protected renderData(d: CalculateVariablesResponse) {
83
- const arrows = d.arrows ?? [];
83
+ // Place the arrows by their bodygraph `position`, not response order: the
84
+ // 2-col grid fills row-major, so sorting to Top left, Top right, Bottom left,
85
+ // Bottom right keeps the design column (Determination + Environment) on the
86
+ // left and the personality column (Motivation + Perspective) on the right,
87
+ // which is the whole point of the four-arrow layout.
88
+ const arrows = [...(d.arrows ?? [])].sort(
89
+ (a, b) => quadrantOrder(a.position) - quadrantOrder(b.position),
90
+ );
84
91
  return html`<div class="wrap" aria-label="Human Design variables">
85
92
  <h2 class="title">Variables</h2>
86
93
  <div class="grid">${arrows.map((a) => this.renderArrow(a))}</div>
@@ -110,7 +117,12 @@ export class RoxyHdVariables extends RoxyDataElement<CalculateVariablesResponse>
110
117
  </span>
111
118
  ${
112
119
  typeof a.color === 'number'
113
- ? html`<span class="ctb">Color ${a.color} · Tone ${a.tone} · Base ${a.base}</span>`
120
+ ? html`<span class="ctb">Color ${a.color} · Tone ${a.tone} · Base ${a.base}${a.activation?.planet ? ` · ${a.activation.planet}${a.activation.side ? ` (${a.activation.side})` : ''}` : ''}</span>`
121
+ : nothing
122
+ }
123
+ ${
124
+ a.confident === false
125
+ ? html`<span class="note" role="note">Knife-edge: could flip with a more precise birth time.</span>`
114
126
  : nothing
115
127
  }
116
128
  </div>`;
@@ -121,6 +133,22 @@ export class RoxyHdVariables extends RoxyDataElement<CalculateVariablesResponse>
121
133
  }
122
134
  }
123
135
 
136
+ /** Canonical bodygraph reading order for the four arrows, so the 2-col grid lays them out by quadrant. Unknown positions sort last. */
137
+ function quadrantOrder(position: string | undefined): number {
138
+ switch (position) {
139
+ case 'Top left':
140
+ return 0;
141
+ case 'Top right':
142
+ return 1;
143
+ case 'Bottom left':
144
+ return 2;
145
+ case 'Bottom right':
146
+ return 3;
147
+ default:
148
+ return 99;
149
+ }
150
+ }
151
+
124
152
  declare global {
125
153
  interface HTMLElementTagNameMap {
126
154
  'roxy-hd-variables': RoxyHdVariables;
@@ -266,18 +266,18 @@ export class RoxyHexagram extends RoxyDataElement<HexagramData> {
266
266
  </article>`;
267
267
  }
268
268
 
269
- /** When the API only ships symbol+number with no line array, render six solid yang. */
269
+ /**
270
+ * Lines for a static hexagram (lookup/random/daily, which carry no cast `lines` array): read the `binary` pattern. Per the spec it is 6 digits bottom to top, 1 = yang (solid), 0 = yin (broken), so index 0 is line 1 (bottom). Mapped to the same 7 = solid / 8 = broken code the renderer uses for cast lines. Falls back to all-yang only if `binary` is malformed. The Unicode `symbol` block (U+4DC0) is in King Wen order, NOT line order, so it must never be used to derive the lines.
271
+ */
270
272
  private derivedLines(h: Hexagram): number[] {
271
- // Map each character of the unicode hexagram block (U+4DC0..) to broken/solid
272
- const cp = h.symbol.codePointAt(0) ?? 0;
273
- if (cp >= 0x4dc0 && cp <= 0x4dff) {
274
- const offset = cp - 0x4dc0;
275
- const lines: number[] = [];
276
- for (let i = 0; i < 6; i++) {
277
- const broken = (offset >> i) & 1;
278
- lines.push(broken ? 8 : 7);
279
- }
280
- return lines;
273
+ const binary = h.binary ?? '';
274
+ if (/^[01]{6}$/.test(binary)) {
275
+ // `binary` is top to bottom (index 0 = line 6), verified against the
276
+ // canonical encoding: hexagram 46 (Earth over Wind) is "000110", which is
277
+ // Earth/Wind only when read top down. The renderer expects bottom to top
278
+ // (line 1 first, like the cast `lines` array), so reverse. 1 = yang
279
+ // (solid, 7), 0 = yin (broken, 8).
280
+ return Array.from(binary, (c) => (c === '1' ? 7 : 8)).reverse();
281
281
  }
282
282
  return Array.from({ length: 6 }, () => 7);
283
283
  }
@@ -0,0 +1,299 @@
1
+ import { css, html, nothing, svg } from 'lit';
2
+ import { customElement } from 'lit/decorators.js';
3
+ import type { LocalSpaceResponse } from '../types/index.js';
4
+ import { RoxyDataElement } from '../utils/base-element.js';
5
+ import { baseStyles } from '../utils/base-styles.js';
6
+ import { planetColor } from '../utils/planet-color.js';
7
+
8
+ type Body = LocalSpaceResponse['bodies'][number];
9
+
10
+ const SIZE = 320;
11
+ const CENTER = SIZE / 2;
12
+ const RIM = 128;
13
+ const SPOKE = 118;
14
+ const GLYPH_R = 140;
15
+ const TICK_LABEL_R = 150;
16
+
17
+ // Compass azimuth (0 = north, clockwise) to a screen point. North is up, east
18
+ // is right, matching how the local space line is read off a real compass.
19
+ function azimuthPoint(az: number, r: number): { x: number; y: number } {
20
+ const rad = (az * Math.PI) / 180;
21
+ return { x: CENTER + r * Math.sin(rad), y: CENTER - r * Math.cos(rad) };
22
+ }
23
+
24
+ const PRINCIPAL = [
25
+ { az: 0, label: 'N' },
26
+ { az: 45, label: 'NE' },
27
+ { az: 90, label: 'E' },
28
+ { az: 135, label: 'SE' },
29
+ { az: 180, label: 'S' },
30
+ { az: 225, label: 'SW' },
31
+ { az: 270, label: 'W' },
32
+ { az: 315, label: 'NW' },
33
+ ];
34
+
35
+ /**
36
+ * Local space compass. Plots each body from a /astrology/local-space response as
37
+ * a directional line radiating from the birthplace at its azimuth (0 = north,
38
+ * clockwise), with a 16-point ring. Bodies below the horizon are dimmed. Color
39
+ * is per body and theme-token driven.
40
+ */
41
+ @customElement('roxy-local-space-compass')
42
+ export class RoxyLocalSpaceCompass extends RoxyDataElement<LocalSpaceResponse> {
43
+ static styles = [
44
+ baseStyles,
45
+ css`
46
+ .wrap {
47
+ width: 100%;
48
+ background: var(--roxy-surface, #fff);
49
+ color: var(--roxy-fg, #0a0a0a);
50
+ border: 1px solid var(--roxy-border, #e4e4e7);
51
+ border-radius: var(--roxy-radius-md, 8px);
52
+ padding: var(--roxy-space-lg, 1.5rem);
53
+ box-shadow: var(--roxy-shadow-sm);
54
+ display: grid;
55
+ gap: var(--roxy-space-md, 1rem);
56
+ }
57
+ .title {
58
+ font-size: var(--roxy-text-lg, 1.125rem);
59
+ font-weight: var(--roxy-weight-bold, 600);
60
+ margin: 0;
61
+ color: var(--roxy-primary, #0f172a);
62
+ }
63
+ .meta {
64
+ color: var(--roxy-muted, #71717a);
65
+ font-size: var(--roxy-text-sm, 0.875rem);
66
+ }
67
+ svg {
68
+ display: block;
69
+ width: 100%;
70
+ max-width: var(--roxy-chart-max-width, 480px);
71
+ aspect-ratio: 1 / 1;
72
+ height: auto;
73
+ margin: 0 auto;
74
+ }
75
+ .dial {
76
+ fill: none;
77
+ stroke: var(--roxy-border, #e4e4e7);
78
+ }
79
+ .dial-fill {
80
+ fill: color-mix(in srgb, var(--roxy-border, #e4e4e7) 10%, transparent);
81
+ stroke: var(--roxy-border, #e4e4e7);
82
+ stroke-width: 1;
83
+ }
84
+ .cardinal-axis {
85
+ stroke: var(--roxy-border, #e4e4e7);
86
+ stroke-width: 0.5;
87
+ }
88
+ .tick {
89
+ stroke: var(--roxy-secondary, #475569);
90
+ stroke-width: 0.6;
91
+ opacity: 0.5;
92
+ }
93
+ .compass-label {
94
+ fill: var(--roxy-secondary, #475569);
95
+ font-size: 9px;
96
+ font-weight: 600;
97
+ font-family: var(--roxy-font-sans);
98
+ }
99
+ .compass-label.cardinal {
100
+ fill: var(--roxy-fg, #0a0a0a);
101
+ }
102
+ .center-dot {
103
+ fill: var(--roxy-fg, #0a0a0a);
104
+ }
105
+ .spoke {
106
+ stroke-width: 1.4;
107
+ }
108
+ .spoke.below {
109
+ stroke-dasharray: 3 3;
110
+ opacity: 0.4;
111
+ }
112
+ .body-glyph {
113
+ font-size: 11px;
114
+ font-weight: 600;
115
+ font-family: var(--roxy-font-sans);
116
+ }
117
+ .body-glyph.below {
118
+ opacity: 0.45;
119
+ }
120
+ .list {
121
+ width: 100%;
122
+ border-collapse: collapse;
123
+ font-size: var(--roxy-text-sm, 0.875rem);
124
+ }
125
+ .list th,
126
+ .list td {
127
+ text-align: left;
128
+ padding: 4px 8px;
129
+ border-bottom: 1px solid var(--roxy-border, #e4e4e7);
130
+ }
131
+ .list th {
132
+ color: var(--roxy-muted, #71717a);
133
+ font-weight: 600;
134
+ text-transform: uppercase;
135
+ letter-spacing: 0.04em;
136
+ font-size: var(--roxy-text-xs, 0.75rem);
137
+ }
138
+ .list td.num {
139
+ text-align: right;
140
+ font-variant-numeric: tabular-nums;
141
+ }
142
+ .body-cell {
143
+ display: inline-flex;
144
+ align-items: center;
145
+ gap: 0.4rem;
146
+ }
147
+ .body-dot {
148
+ width: 10px;
149
+ height: 10px;
150
+ border-radius: 50%;
151
+ flex-shrink: 0;
152
+ }
153
+ .horizon-pill {
154
+ padding: 1px 7px;
155
+ border-radius: var(--roxy-radius-full, 9999px);
156
+ font-size: var(--roxy-text-xs, 0.75rem);
157
+ }
158
+ .horizon-pill.up {
159
+ background: color-mix(in srgb, var(--roxy-success, #16a34a) 16%, transparent);
160
+ color: var(--roxy-success-fg, #166534);
161
+ }
162
+ .horizon-pill.down {
163
+ background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 55%, transparent);
164
+ color: var(--roxy-fg, #0a0a0a);
165
+ }
166
+ .summary {
167
+ color: var(--roxy-fg, #0a0a0a);
168
+ font-size: var(--roxy-text-sm, 0.875rem);
169
+ margin: 0;
170
+ }
171
+ `,
172
+ ];
173
+
174
+ protected renderEmpty() {
175
+ return html`<div class="roxy-empty" role="status">No local space data</div>`;
176
+ }
177
+
178
+ protected renderData(data: LocalSpaceResponse) {
179
+ const bodies = data.bodies ?? [];
180
+ const bd = data.birthDetails;
181
+ return html`<div class="wrap">
182
+ <header>
183
+ <h2 class="title">Local space</h2>
184
+ ${
185
+ bd
186
+ ? html`<div class="meta">${[bd.date, bd.time].filter(Boolean).join(' · ')}</div>`
187
+ : nothing
188
+ }
189
+ </header>
190
+ ${this.renderDial(bodies)}
191
+ ${data.summary ? html`<p class="summary">${data.summary}</p>` : nothing}
192
+ ${this.renderList(bodies)}
193
+ </div>`;
194
+ }
195
+
196
+ private renderDial(bodies: Body[]) {
197
+ return html`<svg
198
+ viewBox="0 0 ${SIZE} ${SIZE}"
199
+ role="img"
200
+ aria-label="Local space compass of planetary directions from the birthplace"
201
+ >
202
+ <title>Local space compass</title>
203
+ <desc>
204
+ A compass centered on the birthplace. Each body is a line pointing to
205
+ its azimuth, clockwise from north. Bodies below the horizon are dimmed.
206
+ </desc>
207
+ <circle class="dial-fill" cx=${CENTER} cy=${CENTER} r=${RIM} />
208
+ <circle class="dial" cx=${CENTER} cy=${CENTER} r=${RIM * 0.66} stroke-width="0.5" />
209
+ <circle class="dial" cx=${CENTER} cy=${CENTER} r=${RIM * 0.33} stroke-width="0.5" />
210
+ ${this.renderCompassRing()}
211
+ ${this.renderSpokes(bodies)}
212
+ <circle class="center-dot" cx=${CENTER} cy=${CENTER} r="2.5" />
213
+ </svg>`;
214
+ }
215
+
216
+ private renderCompassRing() {
217
+ const ticks = [];
218
+ // 16-point ring: a tick every 22.5 degrees.
219
+ for (let az = 0; az < 360; az += 22.5) {
220
+ const outer = azimuthPoint(az, RIM);
221
+ const inner = azimuthPoint(az, RIM - (az % 45 === 0 ? 8 : 4));
222
+ ticks.push(
223
+ svg`<line class="tick" x1=${inner.x} y1=${inner.y} x2=${outer.x} y2=${outer.y} />`,
224
+ );
225
+ }
226
+ // Cardinal cross.
227
+ const ns1 = azimuthPoint(0, RIM);
228
+ const ns2 = azimuthPoint(180, RIM);
229
+ const ew1 = azimuthPoint(90, RIM);
230
+ const ew2 = azimuthPoint(270, RIM);
231
+ const labels = PRINCIPAL.map(({ az, label }) => {
232
+ const pos = azimuthPoint(az, TICK_LABEL_R);
233
+ const cardinal = az % 90 === 0;
234
+ return svg`<text class=${`compass-label${cardinal ? ' cardinal' : ''}`} x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${label}</text>`;
235
+ });
236
+ return svg`
237
+ <line class="cardinal-axis" x1=${ns1.x} y1=${ns1.y} x2=${ns2.x} y2=${ns2.y} />
238
+ <line class="cardinal-axis" x1=${ew1.x} y1=${ew1.y} x2=${ew2.x} y2=${ew2.y} />
239
+ ${ticks}${labels}`;
240
+ }
241
+
242
+ private renderSpokes(bodies: Body[]) {
243
+ return bodies.map((b, i) => {
244
+ const color = planetColor(b.planet, i);
245
+ const below = b.aboveHorizon === false;
246
+ const end = azimuthPoint(b.azimuth, SPOKE);
247
+ const glyphPos = azimuthPoint(b.azimuth, GLYPH_R);
248
+ const glyph = b.symbol || b.planet.slice(0, 2);
249
+ const altLabel = `${b.altitude > 0 ? '+' : ''}${Math.round(b.altitude)}°`;
250
+ return svg`<g>
251
+ <line class=${`spoke${below ? ' below' : ''}`} stroke=${color} x1=${CENTER} y1=${CENTER} x2=${end.x} y2=${end.y}><title>${b.planet} ${b.compassDirection} ${Math.round(b.azimuth)}° altitude ${altLabel}</title></line>
252
+ <text class=${`body-glyph${below ? ' below' : ''}`} fill=${color} x=${glyphPos.x} y=${glyphPos.y} text-anchor="middle" dominant-baseline="central">${glyph}</text>
253
+ </g>`;
254
+ });
255
+ }
256
+
257
+ private renderList(bodies: Body[]) {
258
+ if (bodies.length === 0) return nothing;
259
+ return html`<table class="list">
260
+ <thead>
261
+ <tr>
262
+ <th>Body</th>
263
+ <th>Direction</th>
264
+ <th class="num">Azimuth</th>
265
+ <th class="num">Altitude</th>
266
+ <th>Horizon</th>
267
+ </tr>
268
+ </thead>
269
+ <tbody>
270
+ ${bodies.map((b, i) => {
271
+ const color = planetColor(b.planet, i);
272
+ const below = b.aboveHorizon === false;
273
+ return html`<tr>
274
+ <td>
275
+ <span class="body-cell">
276
+ <span class="body-dot" style=${`background: ${color}`}></span>
277
+ ${b.symbol ? html`${b.symbol} ` : nothing}${b.planet}
278
+ </span>
279
+ </td>
280
+ <td>${b.compassDirection}</td>
281
+ <td class="num">${Math.round(b.azimuth)}°</td>
282
+ <td class="num">${b.altitude > 0 ? '+' : ''}${Math.round(b.altitude)}°</td>
283
+ <td>
284
+ <span class=${`horizon-pill ${below ? 'down' : 'up'}`}>
285
+ ${below ? 'Below' : 'Above'}
286
+ </span>
287
+ </td>
288
+ </tr>`;
289
+ })}
290
+ </tbody>
291
+ </table>`;
292
+ }
293
+ }
294
+
295
+ declare global {
296
+ interface HTMLElementTagNameMap {
297
+ 'roxy-local-space-compass': RoxyLocalSpaceCompass;
298
+ }
299
+ }
@@ -139,7 +139,9 @@ export class RoxyMoonPhase extends RoxyDataElement<MoonPhaseData> {
139
139
  }
140
140
 
141
141
  private renderSingle(d: GetCurrentMoonPhaseResponse) {
142
- const emoji = phaseEmoji(d.phase);
142
+ // The API ships the exact phase emoji in meaning.symbol; prefer it and fall
143
+ // back to the name-derived glyph for the list endpoints that omit meaning.
144
+ const emoji = d.meaning?.symbol || phaseEmoji(d.phase);
143
145
  return html`<article class="card" aria-label="Current moon phase">
144
146
  <div class="hero">
145
147
  <span class="emoji" aria-hidden="true">${emoji}</span>
@@ -207,9 +209,26 @@ export class RoxyMoonPhase extends RoxyDataElement<MoonPhaseData> {
207
209
  }
208
210
  }
209
211
 
212
+ /**
213
+ * Map a phase name to its emoji, tolerant of the live API naming. The API sends
214
+ * suffixed names ("Waxing Gibbous Moon") and "Third Quarter Moon" where the map
215
+ * keys are unsuffixed and use "last quarter"; only "new moon"/"full moon" keep
216
+ * the suffix. Try the raw lowercase, then the suffix stripped, then the
217
+ * third->last quarter alias, then the alias re-suffixed, so every one of the
218
+ * eight phases resolves in both the suffixed and unsuffixed forms.
219
+ */
210
220
  function phaseEmoji(phase: string | undefined): string {
211
221
  if (!phase) return '🌙';
212
- return MOON_PHASE_EMOJI[phase.toLowerCase()] ?? '🌙';
222
+ const lc = phase.toLowerCase().trim();
223
+ const noMoon = lc.replace(/\s*moon$/, '').trim();
224
+ const alias = noMoon === 'third quarter' ? 'last quarter' : noMoon;
225
+ return (
226
+ MOON_PHASE_EMOJI[lc] ??
227
+ MOON_PHASE_EMOJI[noMoon] ??
228
+ MOON_PHASE_EMOJI[alias] ??
229
+ MOON_PHASE_EMOJI[`${alias} moon`] ??
230
+ '🌙'
231
+ );
213
232
  }
214
233
 
215
234
  function formatIllumination(v: number): string {