@roxyapi/ui 0.3.1 → 0.4.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 (165) hide show
  1. package/AGENTS.md +34 -7
  2. package/README.md +145 -26
  3. package/dist/cdn/components/ashtakavarga-grid.js +74 -19
  4. package/dist/cdn/components/ashtakavarga-grid.js.map +2 -2
  5. package/dist/cdn/components/biorhythm-chart.js +18 -4
  6. package/dist/cdn/components/biorhythm-chart.js.map +2 -2
  7. package/dist/cdn/components/choghadiya-grid.js +47 -12
  8. package/dist/cdn/components/choghadiya-grid.js.map +3 -3
  9. package/dist/cdn/components/compatibility-card.js +21 -7
  10. package/dist/cdn/components/compatibility-card.js.map +2 -2
  11. package/dist/cdn/components/dasha-timeline.js +113 -28
  12. package/dist/cdn/components/dasha-timeline.js.map +3 -3
  13. package/dist/cdn/components/data.js +27 -13
  14. package/dist/cdn/components/data.js.map +2 -2
  15. package/dist/cdn/components/divisional-chart.js +225 -118
  16. package/dist/cdn/components/divisional-chart.js.map +4 -4
  17. package/dist/cdn/components/dosha-card.js +18 -4
  18. package/dist/cdn/components/dosha-card.js.map +2 -2
  19. package/dist/cdn/components/endpoint-form.js +25 -11
  20. package/dist/cdn/components/endpoint-form.js.map +2 -2
  21. package/dist/cdn/components/guna-milan.js +20 -6
  22. package/dist/cdn/components/guna-milan.js.map +2 -2
  23. package/dist/cdn/components/hexagram.js +22 -8
  24. package/dist/cdn/components/hexagram.js.map +2 -2
  25. package/dist/cdn/components/horoscope-card.js +20 -6
  26. package/dist/cdn/components/horoscope-card.js.map +2 -2
  27. package/dist/cdn/components/kp-chart.js +19 -5
  28. package/dist/cdn/components/kp-chart.js.map +2 -2
  29. package/dist/cdn/components/kp-planets-table.js +17 -3
  30. package/dist/cdn/components/kp-planets-table.js.map +2 -2
  31. package/dist/cdn/components/kp-ruling-planets.js +17 -3
  32. package/dist/cdn/components/kp-ruling-planets.js.map +2 -2
  33. package/dist/cdn/components/location-search.js +18 -4
  34. package/dist/cdn/components/location-search.js.map +2 -2
  35. package/dist/cdn/components/moon-phase.js +27 -13
  36. package/dist/cdn/components/moon-phase.js.map +2 -2
  37. package/dist/cdn/components/nakshatra-card.js +16 -2
  38. package/dist/cdn/components/nakshatra-card.js.map +2 -2
  39. package/dist/cdn/components/natal-chart.js +79 -40
  40. package/dist/cdn/components/natal-chart.js.map +3 -3
  41. package/dist/cdn/components/numerology-card.js +18 -4
  42. package/dist/cdn/components/numerology-card.js.map +2 -2
  43. package/dist/cdn/components/panchang-table.js +53 -25
  44. package/dist/cdn/components/panchang-table.js.map +3 -3
  45. package/dist/cdn/components/shadbala-table.js +24 -10
  46. package/dist/cdn/components/shadbala-table.js.map +2 -2
  47. package/dist/cdn/components/synastry-chart.js +96 -48
  48. package/dist/cdn/components/synastry-chart.js.map +3 -3
  49. package/dist/cdn/components/tarot-card.js +17 -3
  50. package/dist/cdn/components/tarot-card.js.map +2 -2
  51. package/dist/cdn/components/tarot-spread.js +39 -25
  52. package/dist/cdn/components/tarot-spread.js.map +2 -2
  53. package/dist/cdn/components/transits-table.js +18 -4
  54. package/dist/cdn/components/transits-table.js.map +2 -2
  55. package/dist/cdn/components/vedic-kundli.js +215 -105
  56. package/dist/cdn/components/vedic-kundli.js.map +4 -4
  57. package/dist/cdn/components/vedic-planets-table.js +22 -8
  58. package/dist/cdn/components/vedic-planets-table.js.map +2 -2
  59. package/dist/cdn/components/western-planets-table.js +18 -4
  60. package/dist/cdn/components/western-planets-table.js.map +2 -2
  61. package/dist/cdn/components/yoga-list.js +17 -3
  62. package/dist/cdn/components/yoga-list.js.map +2 -2
  63. package/dist/cdn/roxy-ui.js +1082 -816
  64. package/dist/cdn/roxy-ui.js.map +4 -4
  65. package/dist/components/ashtakavarga-grid.d.ts +13 -1
  66. package/dist/components/ashtakavarga-grid.d.ts.map +1 -1
  67. package/dist/components/ashtakavarga-grid.js +86 -11
  68. package/dist/components/ashtakavarga-grid.js.map +2 -2
  69. package/dist/components/biorhythm-chart.js +14 -0
  70. package/dist/components/biorhythm-chart.js.map +2 -2
  71. package/dist/components/choghadiya-grid.d.ts +6 -0
  72. package/dist/components/choghadiya-grid.d.ts.map +1 -1
  73. package/dist/components/choghadiya-grid.js +50 -2
  74. package/dist/components/choghadiya-grid.js.map +2 -2
  75. package/dist/components/compatibility-card.js +14 -0
  76. package/dist/components/compatibility-card.js.map +2 -2
  77. package/dist/components/dasha-timeline.d.ts +10 -0
  78. package/dist/components/dasha-timeline.d.ts.map +1 -1
  79. package/dist/components/dasha-timeline.js +135 -4
  80. package/dist/components/dasha-timeline.js.map +2 -2
  81. package/dist/components/data.js +14 -0
  82. package/dist/components/data.js.map +2 -2
  83. package/dist/components/divisional-chart.d.ts +9 -6
  84. package/dist/components/divisional-chart.d.ts.map +1 -1
  85. package/dist/components/divisional-chart.js +546 -251
  86. package/dist/components/divisional-chart.js.map +4 -4
  87. package/dist/components/dosha-card.js +14 -0
  88. package/dist/components/dosha-card.js.map +2 -2
  89. package/dist/components/endpoint-form.js +14 -0
  90. package/dist/components/endpoint-form.js.map +2 -2
  91. package/dist/components/guna-milan.js +14 -0
  92. package/dist/components/guna-milan.js.map +2 -2
  93. package/dist/components/hexagram.js +14 -0
  94. package/dist/components/hexagram.js.map +2 -2
  95. package/dist/components/horoscope-card.js +14 -0
  96. package/dist/components/horoscope-card.js.map +2 -2
  97. package/dist/components/kp-chart.js +14 -0
  98. package/dist/components/kp-chart.js.map +2 -2
  99. package/dist/components/kp-planets-table.js +14 -0
  100. package/dist/components/kp-planets-table.js.map +2 -2
  101. package/dist/components/kp-ruling-planets.js +14 -0
  102. package/dist/components/kp-ruling-planets.js.map +2 -2
  103. package/dist/components/location-search.js +14 -0
  104. package/dist/components/location-search.js.map +2 -2
  105. package/dist/components/moon-phase.js +14 -0
  106. package/dist/components/moon-phase.js.map +2 -2
  107. package/dist/components/nakshatra-card.js +14 -0
  108. package/dist/components/nakshatra-card.js.map +2 -2
  109. package/dist/components/natal-chart.d.ts.map +1 -1
  110. package/dist/components/natal-chart.js +76 -6
  111. package/dist/components/natal-chart.js.map +2 -2
  112. package/dist/components/numerology-card.js +14 -0
  113. package/dist/components/numerology-card.js.map +2 -2
  114. package/dist/components/panchang-table.d.ts +1 -0
  115. package/dist/components/panchang-table.d.ts.map +1 -1
  116. package/dist/components/panchang-table.js +37 -1
  117. package/dist/components/panchang-table.js.map +2 -2
  118. package/dist/components/shadbala-table.js +14 -0
  119. package/dist/components/shadbala-table.js.map +2 -2
  120. package/dist/components/synastry-chart.d.ts +6 -0
  121. package/dist/components/synastry-chart.d.ts.map +1 -1
  122. package/dist/components/synastry-chart.js +106 -7
  123. package/dist/components/synastry-chart.js.map +2 -2
  124. package/dist/components/tarot-card.js +14 -0
  125. package/dist/components/tarot-card.js.map +2 -2
  126. package/dist/components/tarot-spread.js +14 -0
  127. package/dist/components/tarot-spread.js.map +2 -2
  128. package/dist/components/transits-table.js +14 -0
  129. package/dist/components/transits-table.js.map +2 -2
  130. package/dist/components/vedic-kundli.d.ts +14 -9
  131. package/dist/components/vedic-kundli.d.ts.map +1 -1
  132. package/dist/components/vedic-kundli.js +537 -245
  133. package/dist/components/vedic-kundli.js.map +4 -4
  134. package/dist/components/vedic-planets-table.js +14 -0
  135. package/dist/components/vedic-planets-table.js.map +2 -2
  136. package/dist/components/western-planets-table.js +14 -0
  137. package/dist/components/western-planets-table.js.map +2 -2
  138. package/dist/components/yoga-list.js +14 -0
  139. package/dist/components/yoga-list.js.map +2 -2
  140. package/dist/index.cjs +1397 -797
  141. package/dist/index.cjs.map +4 -4
  142. package/dist/index.js +1278 -678
  143. package/dist/index.js.map +4 -4
  144. package/dist/manifest.json +23 -23
  145. package/dist/styles/tokens.css +8 -23
  146. package/dist/utils/base-styles.d.ts.map +1 -1
  147. package/dist/utils/kundli-render.d.ts +43 -104
  148. package/dist/utils/kundli-render.d.ts.map +1 -1
  149. package/dist/utils/kundli-styles.d.ts +13 -0
  150. package/dist/utils/kundli-styles.d.ts.map +1 -0
  151. package/dist/version.d.ts +1 -1
  152. package/package.json +1 -1
  153. package/src/components/ashtakavarga-grid.ts +73 -11
  154. package/src/components/choghadiya-grid.ts +37 -2
  155. package/src/components/dasha-timeline.ts +135 -4
  156. package/src/components/divisional-chart.ts +40 -97
  157. package/src/components/natal-chart.ts +89 -6
  158. package/src/components/panchang-table.ts +34 -1
  159. package/src/components/synastry-chart.ts +84 -8
  160. package/src/components/vedic-kundli.ts +35 -95
  161. package/src/styles/tokens.css +8 -23
  162. package/src/utils/base-styles.ts +14 -0
  163. package/src/utils/kundli-render.ts +609 -270
  164. package/src/utils/kundli-styles.ts +124 -0
  165. package/src/version.ts +1 -1
@@ -1,27 +1,39 @@
1
1
  import type { TemplateResult } from 'lit';
2
- import { nothing, svg } from 'lit';
2
+ import { html, nothing, svg } from 'lit';
3
3
  import { PLANET_ABBR, SIGN_ABBR, SIGNS_ORDER } from '../tokens/index.js';
4
4
  import { longitudeToSignPosition } from './degree.js';
5
5
  import { capitalize } from './string.js';
6
6
 
7
- export const KUNDLI_SIZE = 300;
8
- export const KUNDLI_CENTER = 150;
7
+ /**
8
+ * Canonical viewBox geometry for every kundli style. The chart is drawn into a
9
+ * 360-unit square centred in a 400-unit viewBox, leaving a 20-unit gutter for
10
+ * outer labels. All coordinates below are derived from these constants; change
11
+ * them and every cell relocates correctly.
12
+ *
13
+ * @remarks SVG is vector-only and scales without raster loss, so the chart
14
+ * remains crisp from a phone screen to a wall projector. Hosts size the chart
15
+ * by setting `width` on the surrounding container; the SVG keeps a 1:1 aspect
16
+ * ratio via the viewBox.
17
+ */
18
+ const VIEW_BOX = 400;
19
+ const MARGIN = 20;
20
+ const INNER = VIEW_BOX - 2 * MARGIN; // 360
21
+ const CENTRE = VIEW_BOX / 2; // 200
9
22
 
10
23
  /**
11
- * Maps a lowercase rashi key (e.g. "aries") back to its canonical sign name
12
- * (e.g. "Aries"). Used by every kundli consumer to bridge spec lowercase
13
- * rashi keys to the title-cased SIGNS_ORDER tokens.
24
+ * Lowercase rashi key (`"aries"`) to canonical title-cased sign name (`"Aries"`).
25
+ * Bridges API lowercase rashi strings to the SIGNS_ORDER tokens used everywhere
26
+ * else in the render.
14
27
  */
15
- export const RASHI_TO_SIGN: Record<string, string> = Object.fromEntries(
28
+ const RASHI_TO_SIGN: Record<string, string> = Object.fromEntries(
16
29
  SIGNS_ORDER.map((s) => [s.toLowerCase(), s] as const),
17
30
  );
18
31
 
19
32
  /**
20
- * A planet placed in a kundli house. This is a render-only view model, not an
21
- * API type: it carries just enough per-graha detail to draw a compact label
22
- * (abbreviation plus degree-within-sign plus retrograde mark) and a rich SVG
23
- * `<title>` tooltip (full position, nakshatra, pada, avastha). Both the D1
24
- * birth chart and the Dx divisional charts feed it from their `meta` map.
33
+ * A graha placed inside a kundli cell. Render-only view model fed from a
34
+ * `meta` map on the API response. Carries enough detail to draw a compact
35
+ * in-cell label (abbreviation, whole degree, retrograde mark) and a rich SVG
36
+ * `<title>` tooltip (exact position, nakshatra, pada, avastha).
25
37
  */
26
38
  export interface PlacedGraha {
27
39
  graha: string;
@@ -31,28 +43,67 @@ export interface PlacedGraha {
31
43
  awastha?: string;
32
44
  }
33
45
 
34
- export interface HouseDef {
35
- /** 1-based cell number. For the sign-fixed styles (south, east) this is the rashi index, Aries = 1. */
36
- number: number;
37
- /** Sign name (TitleCase, e.g. "Aries"). */
38
- sign: string;
39
- /** Planets occupying this house, with full detail for label + tooltip. */
40
- planets: PlacedGraha[];
41
- /** Whether this house is the ascendant (Lagna). */
42
- isLagna: boolean;
46
+ /**
47
+ * Unified view model used by every kundli style. Caller passes a graha-keyed
48
+ * `meta` map (from `/vedic-astrology/birth-chart`, `/divisional-chart`, or
49
+ * `/navamsa`) through {@link toKundliViewModel} to produce this shape.
50
+ *
51
+ * `placements` is keyed by lowercase rashi name (`"aries"`, `"taurus"`, ...)
52
+ * so the sign-fixed styles can index directly. The Lagna entry is not
53
+ * counted as a planet; it only flags the ascendant cell.
54
+ */
55
+ export interface KundliViewModel {
56
+ lagnaSign: string;
57
+ placements: Record<string, PlacedGraha[]>;
58
+ divisionLabel?: string;
43
59
  }
44
60
 
45
- /** Superscript "r" used as a compact retrograde marker on planet labels. */
61
+ /**
62
+ * Kundli regional styles. Sign-fixed (south, east) and house-fixed (north).
63
+ * Exposed so consumers can type their own `chart-style` attribute reflection.
64
+ */
65
+ export type ChartStyle = 'south' | 'north' | 'east';
66
+
67
+ const CHART_STYLES: ReadonlyArray<{ id: ChartStyle; label: string }> = [
68
+ { id: 'north', label: 'North' },
69
+ { id: 'south', label: 'South' },
70
+ { id: 'east', label: 'East' },
71
+ ];
72
+
46
73
  const RETRO_MARK = 'ʳ';
47
74
 
48
75
  /**
49
- * Compact in-cell label for a placed graha: abbreviation, whole-degree within
50
- * the sign, and a retrograde mark. Degree is omitted when longitude is absent.
76
+ * True when the placed graha's longitude maps to a sign other than the cell
77
+ * it occupies. The API preserves the D1 sidereal longitude on every chart, so
78
+ * inside a D2..D60 cell that longitude refers to the D1 sign, not the cell's
79
+ * divisional sign. In that case the degree-within-sign is not meaningful and
80
+ * must be hidden from the in-cell label.
81
+ */
82
+ function isDivisionalPlacement(p: PlacedGraha, cellSign: string): boolean {
83
+ if (typeof p.longitude !== 'number' || !Number.isFinite(p.longitude)) {
84
+ return false;
85
+ }
86
+ return (
87
+ longitudeToSignPosition(p.longitude).sign.toLowerCase() !==
88
+ cellSign.toLowerCase()
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Compact in-cell graha label: abbreviation, optional whole-degree, retrograde
94
+ * mark. The degree is shown only when the longitude actually maps to the cell
95
+ * the graha is rendered in (the D1 case); divisional placements show the
96
+ * abbreviation alone since the API longitude refers to D1, not the divisional
97
+ * sign.
51
98
  */
52
- function grahaLabel(p: PlacedGraha): string {
99
+ function grahaLabel(p: PlacedGraha, cellSign: string): string {
53
100
  const abbr = PLANET_ABBR[capitalize(p.graha)] ?? p.graha.slice(0, 2);
54
101
  const retro = p.isRetrograde ? RETRO_MARK : '';
55
- if (typeof p.longitude !== 'number' || !Number.isFinite(p.longitude)) {
102
+ if (
103
+ typeof p.longitude !== 'number' ||
104
+ !Number.isFinite(p.longitude) ||
105
+ isDivisionalPlacement(p, cellSign)
106
+ ) {
56
107
  return `${abbr}${retro}`;
57
108
  }
58
109
  const { degree } = longitudeToSignPosition(p.longitude);
@@ -60,16 +111,25 @@ function grahaLabel(p: PlacedGraha): string {
60
111
  }
61
112
 
62
113
  /**
63
- * Full-detail tooltip text for a placed graha: name, exact degree and minute,
64
- * nakshatra and pada, avastha, retrograde. Surfaced via an SVG `<title>` so the
65
- * chart cell itself stays compact.
114
+ * Full-detail tooltip surfaced via the SVG `<title>` for each planet label.
115
+ * Includes planet name, the divisional placement (when the longitude does not
116
+ * match the cell, the cell's rashi is preferred), the exact D1 longitude as
117
+ * the original reference, nakshatra and pada, avastha, and the retrograde
118
+ * flag. Surfaces on hover or long-press without crowding the cell.
66
119
  */
67
- function grahaTitle(p: PlacedGraha): string {
120
+ function grahaTitle(p: PlacedGraha, cellSign: string): string {
68
121
  const parts: string[] = [capitalize(p.graha)];
122
+ const divisional = isDivisionalPlacement(p, cellSign);
123
+ if (divisional) {
124
+ parts.push(`in ${cellSign}`);
125
+ }
69
126
  if (typeof p.longitude === 'number' && Number.isFinite(p.longitude)) {
70
127
  const sp = longitudeToSignPosition(p.longitude);
128
+ const minute = String(sp.minute).padStart(2, '0');
71
129
  parts.push(
72
- `${sp.degree}°${String(sp.minute).padStart(2, '0')}' ${sp.sign}`,
130
+ divisional
131
+ ? `D1: ${sp.degree}°${minute}' ${sp.sign}`
132
+ : `${sp.degree}°${minute}' ${sp.sign}`,
73
133
  );
74
134
  }
75
135
  if (p.nakshatra?.name) {
@@ -82,321 +142,600 @@ function grahaTitle(p: PlacedGraha): string {
82
142
  }
83
143
 
84
144
  /**
85
- * Render the stack of planet labels for one house cell. Shared by all three
86
- * styles: vertically centers the stack on `baseY`, one line per planet, each
87
- * with a `<title>` tooltip carrying the full detail.
145
+ * Render a vertically centred stack of planet labels at `(cx, baseY)`, one
146
+ * line per planet, with an SVG `<title>` per line carrying the full tooltip.
147
+ * The stack auto-centres on `baseY` regardless of count so a 1-planet cell
148
+ * and a 5-planet cell both look intentional.
88
149
  */
89
150
  function renderPlanetStack(
90
151
  planets: PlacedGraha[],
152
+ cellSign: string,
91
153
  cx: number,
92
154
  baseY: number,
93
155
  lineHeight: number,
94
- ): (TemplateResult | typeof nothing)[] {
156
+ ): TemplateResult[] {
95
157
  const startY = baseY - ((planets.length - 1) * lineHeight) / 2;
96
158
  return planets.map((p, j) => {
97
159
  const yPos = startY + j * lineHeight;
98
160
  return svg`<text class="planet-text" x=${cx} y=${yPos} text-anchor="middle" dominant-baseline="central">${grahaLabel(
99
161
  p,
100
- )}<title>${grahaTitle(p)}</title></text>`;
162
+ cellSign,
163
+ )}<title>${grahaTitle(p, cellSign)}</title></text>`;
101
164
  });
102
165
  }
103
166
 
104
167
  /**
105
- * South Indian fixed-house square grid: house centers for planet text labels.
106
- * House 1 is fixed top-center; positions are in the 300x300 viewBox.
168
+ * Bucket a graha-keyed `meta` map (D1 birth chart or D2..D60 divisional
169
+ * chart) into the unified {@link KundliViewModel} the renderer consumes. The
170
+ * Lagna entry is recognised by `graha === 'Lagna'` (or key `"Lagna"`) and
171
+ * sets `lagnaSign`; it is not bucketed as a placed planet.
172
+ *
173
+ * @param meta - Graha-keyed map; missing rashi entries are skipped.
174
+ * @param divisionLabel - Optional title written inside the chart centre.
107
175
  */
108
- export const SOUTH_HOUSE_CENTERS: Record<number, { x: number; y: number }> = {
109
- 1: { x: 150, y: 58 },
110
- 2: { x: 205, y: 52 },
111
- 3: { x: 253, y: 112 },
112
- 4: { x: 243, y: 150 },
113
- 5: { x: 253, y: 188 },
114
- 6: { x: 205, y: 248 },
115
- 7: { x: 150, y: 242 },
116
- 8: { x: 95, y: 248 },
117
- 9: { x: 47, y: 188 },
118
- 10: { x: 57, y: 150 },
119
- 11: { x: 47, y: 112 },
120
- 12: { x: 95, y: 52 },
121
- };
176
+ export function toKundliViewModel(
177
+ meta: Record<
178
+ string,
179
+ {
180
+ graha?: string;
181
+ rashi?: string;
182
+ longitude?: number;
183
+ nakshatra?: { name?: string; pada?: number; lord?: string };
184
+ isRetrograde?: boolean;
185
+ awastha?: string;
186
+ }
187
+ >,
188
+ divisionLabel?: string,
189
+ ): KundliViewModel {
190
+ const placements: Record<string, PlacedGraha[]> = {};
191
+ for (const sign of SIGNS_ORDER) placements[sign.toLowerCase()] = [];
192
+ let lagnaSign = '';
193
+ for (const [name, pos] of Object.entries(meta ?? {})) {
194
+ const rashiKey = (pos?.rashi ?? '').toLowerCase();
195
+ if (name === 'Lagna' || pos?.graha === 'Lagna') {
196
+ lagnaSign = RASHI_TO_SIGN[rashiKey] ?? '';
197
+ continue;
198
+ }
199
+ if (!rashiKey || !(rashiKey in placements)) continue;
200
+ placements[rashiKey]?.push({
201
+ graha: pos.graha ?? name,
202
+ longitude: pos.longitude,
203
+ nakshatra: pos.nakshatra,
204
+ isRetrograde: pos.isRetrograde,
205
+ awastha: pos.awastha,
206
+ });
207
+ }
208
+ return { lagnaSign, placements, divisionLabel };
209
+ }
122
210
 
123
- /**
124
- * South Indian sign abbreviation positions (slightly outward from center).
125
- */
126
- export const SOUTH_SIGN_POSITIONS: Record<number, { x: number; y: number }> = {
127
- 1: { x: 150, y: 35 },
128
- 2: { x: 222, y: 40 },
129
- 3: { x: 265, y: 100 },
130
- 4: { x: 265, y: 150 },
131
- 5: { x: 265, y: 200 },
132
- 6: { x: 222, y: 260 },
133
- 7: { x: 150, y: 265 },
134
- 8: { x: 78, y: 260 },
135
- 9: { x: 35, y: 200 },
136
- 10: { x: 35, y: 150 },
137
- 11: { x: 35, y: 100 },
138
- 12: { x: 78, y: 40 },
139
- };
211
+ // ---------------------------------------------------------------------------
212
+ // South Indian: 4x4 grid with central 2x2 hollow. Signs are FIXED to cells
213
+ // (Pisces top-left corner, clockwise); houses rotate from the Lagna cell.
214
+ // ---------------------------------------------------------------------------
140
215
 
141
- /**
142
- * North Indian style: 12 triangular house positions.
143
- * Lagna (house 1) is the top diamond, numbered clockwise.
144
- * Centers represent the visual midpoint of each triangular cell.
145
- */
146
- export const NORTH_HOUSE_CENTERS: Record<number, { x: number; y: number }> = {
147
- 1: { x: 150, y: 60 },
148
- 2: { x: 225, y: 100 },
149
- 3: { x: 255, y: 150 },
150
- 4: { x: 225, y: 200 },
151
- 5: { x: 150, y: 240 },
152
- 6: { x: 75, y: 200 },
153
- 7: { x: 45, y: 150 },
154
- 8: { x: 75, y: 100 },
155
- 9: { x: 100, y: 80 },
156
- 10: { x: 150, y: 108 },
157
- 11: { x: 200, y: 80 },
158
- 12: { x: 200, y: 220 },
159
- };
216
+ const SOUTH_CELL = INNER / 4; // 90
160
217
 
161
218
  /**
162
- * East Indian style: a fixed-sign square (like South Indian) cut by both
163
- * diagonals and an inner diamond joining the side midpoints, giving 12 cells.
164
- * The four inner-diamond quadrilaterals hold the cardinal-position signs
165
- * (cell 1, 4, 7, 10) and the eight corner half-triangles fill between them,
166
- * laid out clockwise from the top so cell `n` holds the n-th rashi (Aries = 1).
167
- * Centers are the visual midpoints of those cells in the 300x300 viewBox,
168
- * derived from the frame geometry (square 10..290, diagonals, side-midpoint
169
- * diamond).
170
- *
171
- * @remarks The cell geometry is exact; the rashi-to-cell order follows the
172
- * common clockwise-from-top convention and is slated for a regional
173
- * reference-image confirmation pass (see docs/todo.md "East Indian polish").
219
+ * Sign-to-cell column/row in the South Indian fixed-sign grid. Pisces sits in
220
+ * the top-left corner and the remaining signs proceed clockwise around the
221
+ * 12 perimeter cells. (col, row) origin is the chart top-left.
174
222
  */
175
- export const EAST_HOUSE_CENTERS: Record<number, { x: number; y: number }> = {
176
- 1: { x: 150, y: 80 }, // inner diamond, top
177
- 2: { x: 220, y: 33 }, // top-right corner, upper triangle
178
- 3: { x: 267, y: 80 }, // top-right corner, right triangle
179
- 4: { x: 220, y: 150 }, // inner diamond, right
180
- 5: { x: 267, y: 220 }, // bottom-right corner, right triangle
181
- 6: { x: 220, y: 267 }, // bottom-right corner, lower triangle
182
- 7: { x: 150, y: 220 }, // inner diamond, bottom
183
- 8: { x: 80, y: 267 }, // bottom-left corner, lower triangle
184
- 9: { x: 33, y: 220 }, // bottom-left corner, left triangle
185
- 10: { x: 80, y: 150 }, // inner diamond, left
186
- 11: { x: 33, y: 80 }, // top-left corner, left triangle
187
- 12: { x: 80, y: 33 }, // top-left corner, upper triangle
223
+ const SOUTH_CELL_GRID: Record<string, { col: number; row: number }> = {
224
+ Pisces: { col: 0, row: 0 },
225
+ Aries: { col: 1, row: 0 },
226
+ Taurus: { col: 2, row: 0 },
227
+ Gemini: { col: 3, row: 0 },
228
+ Cancer: { col: 3, row: 1 },
229
+ Leo: { col: 3, row: 2 },
230
+ Virgo: { col: 3, row: 3 },
231
+ Libra: { col: 2, row: 3 },
232
+ Scorpio: { col: 1, row: 3 },
233
+ Sagittarius: { col: 0, row: 3 },
234
+ Capricorn: { col: 0, row: 2 },
235
+ Aquarius: { col: 0, row: 1 },
188
236
  };
189
237
 
238
+ function southCellRect(sign: string): {
239
+ x: number;
240
+ y: number;
241
+ w: number;
242
+ h: number;
243
+ } {
244
+ const g = SOUTH_CELL_GRID[sign] ?? { col: 0, row: 0 };
245
+ return {
246
+ x: MARGIN + g.col * SOUTH_CELL,
247
+ y: MARGIN + g.row * SOUTH_CELL,
248
+ w: SOUTH_CELL,
249
+ h: SOUTH_CELL,
250
+ };
251
+ }
252
+
190
253
  /**
191
- * East Indian sign abbreviation positions, nudged toward the outer edge of
192
- * every cell so the abbreviation and the planet stack do not collide.
254
+ * South Indian frame: outer square, the two full-span grid lines, and the
255
+ * partial inner lines that bound the central 2x2 hollow on each edge.
193
256
  */
194
- export const EAST_SIGN_POSITIONS: Record<number, { x: number; y: number }> = {
195
- 1: { x: 150, y: 55 },
196
- 2: { x: 235, y: 24 },
197
- 3: { x: 276, y: 62 },
198
- 4: { x: 242, y: 150 },
199
- 5: { x: 276, y: 238 },
200
- 6: { x: 235, y: 276 },
201
- 7: { x: 150, y: 245 },
202
- 8: { x: 65, y: 276 },
203
- 9: { x: 24, y: 238 },
204
- 10: { x: 58, y: 150 },
205
- 11: { x: 24, y: 62 },
206
- 12: { x: 65, y: 24 },
207
- };
257
+ function renderSouthFrame(divisionLabel?: string): TemplateResult {
258
+ const a = MARGIN;
259
+ const b = MARGIN + SOUTH_CELL; // 110
260
+ const c = MARGIN + 2 * SOUTH_CELL; // 200
261
+ const d = MARGIN + 3 * SOUTH_CELL; // 290
262
+ const e = VIEW_BOX - MARGIN; // 380
263
+ return svg`
264
+ <rect class="line" x=${a} y=${a} width=${INNER} height=${INNER} stroke-width="1.5" fill="none" />
265
+ <line class="line" x1=${a} y1=${b} x2=${e} y2=${b} stroke-width="1" />
266
+ <line class="line" x1=${a} y1=${d} x2=${e} y2=${d} stroke-width="1" />
267
+ <line class="line" x1=${b} y1=${a} x2=${b} y2=${e} stroke-width="1" />
268
+ <line class="line" x1=${d} y1=${a} x2=${d} y2=${e} stroke-width="1" />
269
+ <line class="line" x1=${a} y1=${c} x2=${b} y2=${c} stroke-width="1" />
270
+ <line class="line" x1=${d} y1=${c} x2=${e} y2=${c} stroke-width="1" />
271
+ <line class="line" x1=${c} y1=${a} x2=${c} y2=${b} stroke-width="1" />
272
+ <line class="line" x1=${c} y1=${d} x2=${c} y2=${e} stroke-width="1" />
273
+ ${
274
+ divisionLabel
275
+ ? svg`<text class="centre-label" x=${CENTRE} y=${CENTRE} text-anchor="middle" dominant-baseline="central">${divisionLabel}</text>`
276
+ : nothing
277
+ }
278
+ `;
279
+ }
208
280
 
209
281
  /**
210
- * Render a single south-Indian house group: lagna highlight, sign
211
- * abbreviation, planet labels with degree and tooltip.
282
+ * House number for a given sign relative to a Lagna sign. House 1 is the
283
+ * Lagna cell; subsequent houses follow the zodiac in order. Returns 0 when
284
+ * the Lagna sign is unknown so the caller can skip rendering the badge.
212
285
  */
213
- export function renderSouthHouseGroup(
214
- h: HouseDef,
215
- ): TemplateResult | typeof nothing {
216
- const center = SOUTH_HOUSE_CENTERS[h.number];
217
- const signPos = SOUTH_SIGN_POSITIONS[h.number];
218
- if (!center || !signPos) return nothing;
219
- const signAbbr = SIGN_ABBR[h.sign] ?? '';
220
- const baseY = h.isLagna ? center.y + 8 : center.y;
286
+ function houseNumberInSign(sign: string, lagnaSign: string): number {
287
+ const lagnaIdx = SIGNS_ORDER.findIndex((s) => s === lagnaSign);
288
+ const signIdx = SIGNS_ORDER.findIndex((s) => s === sign);
289
+ if (lagnaIdx === -1 || signIdx === -1) return 0;
290
+ return ((signIdx - lagnaIdx + 12) % 12) + 1;
291
+ }
292
+
293
+ function renderSouthCell(
294
+ sign: string,
295
+ planets: PlacedGraha[],
296
+ isLagna: boolean,
297
+ houseNum: number,
298
+ ): TemplateResult {
299
+ const r = southCellRect(sign);
300
+ const cx = r.x + r.w / 2;
301
+ const cy = r.y + r.h / 2;
302
+ const signAbbr = SIGN_ABBR[sign] ?? sign.slice(0, 2);
303
+ // Inset the Lagna diagonal so it does not collide with the chart frame on
304
+ // corner cells (Pisces, Gemini, Virgo, Sagittarius) or with the sign label
305
+ // in the top-left of every cell.
306
+ const slashInset = 14;
221
307
  return svg`
222
- <g>
308
+ <g class=${isLagna ? 'cell lagna' : 'cell'}>
223
309
  ${
224
- h.isLagna
225
- ? svg`<rect
226
- class="lagna-bg"
227
- x=${center.x - 30} y=${center.y - 28}
228
- width="60" height="56" rx="6"
229
- />`
310
+ isLagna
311
+ ? svg`
312
+ <rect class="lagna-bg" x=${r.x} y=${r.y} width=${r.w} height=${r.h} />
313
+ <line class="lagna-slash" x1=${r.x + r.w - slashInset} y1=${r.y + slashInset} x2=${r.x + slashInset} y2=${r.y + r.h - slashInset} stroke-width="1.2" />
314
+ `
230
315
  : nothing
231
316
  }
317
+ <text class="sign-text" x=${r.x + 6} y=${r.y + 12} text-anchor="start" dominant-baseline="central">${signAbbr}</text>
232
318
  ${
233
- signAbbr
234
- ? svg`<text class="sign-text" x=${signPos.x} y=${signPos.y} text-anchor="middle" dominant-baseline="central">${signAbbr}</text>`
319
+ houseNum > 0
320
+ ? svg`<text class="house-num" x=${r.x + r.w - 6} y=${r.y + 12} text-anchor="end" dominant-baseline="central">${houseNum}</text>`
235
321
  : nothing
236
322
  }
237
- ${
238
- h.isLagna
239
- ? svg`<text class="lagna-marker" x=${center.x} y=${center.y - 18} text-anchor="middle" dominant-baseline="central">LAGNA</text>`
240
- : nothing
241
- }
242
- ${renderPlanetStack(h.planets, center.x, baseY, 13)}
323
+ ${planets.length ? renderPlanetStack(planets, sign, cx, cy + 4, 14) : nothing}
243
324
  </g>
244
325
  `;
245
326
  }
246
327
 
247
- /**
248
- * Render a north-Indian-style kundli wheel frame (grid lines only).
249
- * Returns the SVG structural lines; call `renderNorthHouseGroup` for content.
250
- */
251
- export function renderNorthFrame(): TemplateResult {
328
+ function renderSouthSvg(vm: KundliViewModel): TemplateResult {
329
+ const lagnaKey = vm.lagnaSign.toLowerCase();
252
330
  return svg`
253
- <polygon class="line" points="150,10 290,150 150,290 10,150" stroke-width="1.5" />
254
- <line class="line" x1="150" y1="10" x2="150" y2="290" stroke-width="1" />
255
- <line class="line" x1="10" y1="150" x2="290" y2="150" stroke-width="1" />
256
- <line class="line" x1="150" y1="10" x2="10" y2="150" stroke-width="0.6" stroke-dasharray="3,3" />
257
- <line class="line" x1="150" y1="10" x2="290" y2="150" stroke-width="0.6" stroke-dasharray="3,3" />
258
- <line class="line" x1="150" y1="290" x2="10" y2="150" stroke-width="0.6" stroke-dasharray="3,3" />
259
- <line class="line" x1="150" y1="290" x2="290" y2="150" stroke-width="0.6" stroke-dasharray="3,3" />
331
+ ${renderSouthFrame(vm.divisionLabel)}
332
+ ${SIGNS_ORDER.map((sign) =>
333
+ renderSouthCell(
334
+ sign,
335
+ vm.placements[sign.toLowerCase()] ?? [],
336
+ sign.toLowerCase() === lagnaKey,
337
+ houseNumberInSign(sign, vm.lagnaSign),
338
+ ),
339
+ )}
260
340
  `;
261
341
  }
262
342
 
343
+ // ---------------------------------------------------------------------------
344
+ // North Indian: outer square + inscribed midpoint diamond + both outer
345
+ // diagonals. 12 cells: 4 cardinal diamonds + 8 corner triangles. Houses are
346
+ // FIXED (H1 always top-centre); signs rotate from the Lagna sign.
347
+ // ---------------------------------------------------------------------------
348
+
349
+ const NORTH_VERTICES = {
350
+ tl: { x: MARGIN, y: MARGIN },
351
+ tr: { x: VIEW_BOX - MARGIN, y: MARGIN },
352
+ br: { x: VIEW_BOX - MARGIN, y: VIEW_BOX - MARGIN },
353
+ bl: { x: MARGIN, y: VIEW_BOX - MARGIN },
354
+ top: { x: CENTRE, y: MARGIN },
355
+ right: { x: VIEW_BOX - MARGIN, y: CENTRE },
356
+ bottom: { x: CENTRE, y: VIEW_BOX - MARGIN },
357
+ left: { x: MARGIN, y: CENTRE },
358
+ tlMid: { x: CENTRE - INNER / 4, y: CENTRE - INNER / 4 },
359
+ trMid: { x: CENTRE + INNER / 4, y: CENTRE - INNER / 4 },
360
+ brMid: { x: CENTRE + INNER / 4, y: CENTRE + INNER / 4 },
361
+ blMid: { x: CENTRE - INNER / 4, y: CENTRE + INNER / 4 },
362
+ } as const;
363
+
263
364
  /**
264
- * Render a north-Indian house group (sign abbr + house number + planets).
365
+ * Centroid (geometric mean) of an arbitrary set of polygon vertices. Used by
366
+ * every cell that needs a label-anchor point; defining it once keeps the
367
+ * North diamond and East triangle math identical.
265
368
  */
266
- export function renderNorthHouseGroup(
267
- h: HouseDef,
268
- ): TemplateResult | typeof nothing {
269
- const center = NORTH_HOUSE_CENTERS[h.number];
270
- if (!center) return nothing;
271
- const signAbbr = SIGN_ABBR[h.sign] ?? '';
369
+ function centroidOf(pts: Array<{ x: number; y: number }>): {
370
+ x: number;
371
+ y: number;
372
+ } {
373
+ const x = pts.reduce((s, p) => s + p.x, 0) / pts.length;
374
+ const y = pts.reduce((s, p) => s + p.y, 0) / pts.length;
375
+ return { x, y };
376
+ }
377
+
378
+ /**
379
+ * House centres for the North Indian diamond. Numbered 1..12 counter-clockwise
380
+ * from the top diamond (H1 is always the ascendant cell). Centroids derived
381
+ * from the canonical geometry above; do not edit by eye, recompute if you
382
+ * change `VIEW_BOX` or `MARGIN`.
383
+ */
384
+ const NORTH_HOUSE_CENTERS: Record<number, { x: number; y: number }> = {
385
+ 1: { x: CENTRE, y: NORTH_VERTICES.tlMid.y },
386
+ 2: centroidOf([NORTH_VERTICES.tl, NORTH_VERTICES.top, NORTH_VERTICES.tlMid]),
387
+ 3: centroidOf([NORTH_VERTICES.tl, NORTH_VERTICES.left, NORTH_VERTICES.tlMid]),
388
+ 4: { x: NORTH_VERTICES.tlMid.x, y: CENTRE },
389
+ 5: centroidOf([NORTH_VERTICES.bl, NORTH_VERTICES.left, NORTH_VERTICES.blMid]),
390
+ 6: centroidOf([
391
+ NORTH_VERTICES.bl,
392
+ NORTH_VERTICES.bottom,
393
+ NORTH_VERTICES.blMid,
394
+ ]),
395
+ 7: { x: CENTRE, y: NORTH_VERTICES.blMid.y },
396
+ 8: centroidOf([
397
+ NORTH_VERTICES.br,
398
+ NORTH_VERTICES.bottom,
399
+ NORTH_VERTICES.brMid,
400
+ ]),
401
+ 9: centroidOf([
402
+ NORTH_VERTICES.br,
403
+ NORTH_VERTICES.right,
404
+ NORTH_VERTICES.brMid,
405
+ ]),
406
+ 10: { x: NORTH_VERTICES.brMid.x, y: CENTRE },
407
+ 11: centroidOf([
408
+ NORTH_VERTICES.tr,
409
+ NORTH_VERTICES.right,
410
+ NORTH_VERTICES.trMid,
411
+ ]),
412
+ 12: centroidOf([NORTH_VERTICES.tr, NORTH_VERTICES.top, NORTH_VERTICES.trMid]),
413
+ };
414
+
415
+ /**
416
+ * Rashi number (1..12, Aries=1) occupying the given house when the Lagna sits
417
+ * in `lagnaSign`. House 1 is the Lagna sign; subsequent houses follow the
418
+ * zodiac in order.
419
+ */
420
+ function rashiInHouse(houseNum: number, lagnaSign: string): number {
421
+ const lagnaIdx = SIGNS_ORDER.findIndex((s) => s === lagnaSign);
422
+ if (lagnaIdx === -1) return houseNum;
423
+ return ((lagnaIdx + houseNum - 1) % 12) + 1;
424
+ }
425
+
426
+ function renderNorthFrame(divisionLabel?: string): TemplateResult {
427
+ const { tl, tr, br, bl, top, right, bottom, left } = NORTH_VERTICES;
272
428
  return svg`
273
- <g>
274
- ${
275
- h.isLagna
276
- ? svg`<circle class="lagna-bg" cx=${center.x} cy=${center.y} r="22" />`
277
- : nothing
278
- }
429
+ <rect class="line" x=${tl.x} y=${tl.y} width=${INNER} height=${INNER} stroke-width="1.5" fill="none" />
430
+ <polygon class="line" points="${top.x},${top.y} ${right.x},${right.y} ${bottom.x},${bottom.y} ${left.x},${left.y}" stroke-width="1" fill="none" />
431
+ <line class="line" x1=${tl.x} y1=${tl.y} x2=${br.x} y2=${br.y} stroke-width="1" />
432
+ <line class="line" x1=${tr.x} y1=${tr.y} x2=${bl.x} y2=${bl.y} stroke-width="1" />
433
+ ${
434
+ divisionLabel
435
+ ? svg`<text class="centre-label" x=${CENTRE} y=${CENTRE} text-anchor="middle" dominant-baseline="central">${divisionLabel}</text>`
436
+ : nothing
437
+ }
438
+ `;
439
+ }
440
+
441
+ function renderNorthCell(
442
+ houseNum: number,
443
+ rashiNum: number,
444
+ sign: string,
445
+ planets: PlacedGraha[],
446
+ isLagna: boolean,
447
+ ): TemplateResult {
448
+ const c = NORTH_HOUSE_CENTERS[houseNum];
449
+ if (!c) return svg``;
450
+ // Tight cells (H2/3/5/6/8/9/11/12 corner triangles) clip the rasi number
451
+ // when it sits too high above the centroid. Clamp the upward offset based
452
+ // on the cell's distance from the chart vertical centre so the label
453
+ // always stays comfortably inside its triangle or diamond.
454
+ const rashiOffsetY = Math.min(14, Math.abs(c.y - CENTRE) * 0.45 + 6);
455
+ const ascOffsetY = rashiOffsetY + 12;
456
+ return svg`
457
+ <g class=${isLagna ? 'cell lagna' : 'cell'}>
458
+ <text class="rashi-num" x=${c.x} y=${c.y - rashiOffsetY} text-anchor="middle" dominant-baseline="central">${rashiNum}</text>
279
459
  ${
280
- signAbbr
281
- ? svg`<text class="sign-text" x=${center.x} y=${center.y - 10} text-anchor="middle" dominant-baseline="central">${signAbbr}</text>`
460
+ isLagna
461
+ ? svg`<text class="lagna-marker" x=${c.x} y=${c.y - ascOffsetY} text-anchor="middle" dominant-baseline="central">Asc</text>`
282
462
  : nothing
283
463
  }
284
- <text class="house-num" x=${center.x} y=${center.y + 2} text-anchor="middle" dominant-baseline="central">${h.number}</text>
285
- ${renderPlanetStack(h.planets, center.x, center.y + 14, 11)}
464
+ ${planets.length ? renderPlanetStack(planets, sign, c.x, c.y + 8, 12) : nothing}
286
465
  </g>
287
466
  `;
288
467
  }
289
468
 
290
- /**
291
- * Render the south-Indian square frame (border diamond + inner square + radial lines).
292
- */
293
- export function renderSouthFrame(): TemplateResult {
469
+ function renderNorthSvg(vm: KundliViewModel): TemplateResult {
470
+ const lagnaSign = vm.lagnaSign || 'Aries';
294
471
  return svg`
295
- <polygon class="line" points="150,10 290,150 150,290 10,150" stroke-width="1.5" />
296
- <polygon class="line" points="220,80 220,220 80,220 80,80" stroke-width="1" fill="none" />
297
- <line class="line" x1="150" y1="10" x2="80" y2="80" stroke-width="1" />
298
- <line class="line" x1="150" y1="10" x2="220" y2="80" stroke-width="1" />
299
- <line class="line" x1="290" y1="150" x2="220" y2="80" stroke-width="1" />
300
- <line class="line" x1="290" y1="150" x2="220" y2="220" stroke-width="1" />
301
- <line class="line" x1="150" y1="290" x2="220" y2="220" stroke-width="1" />
302
- <line class="line" x1="150" y1="290" x2="80" y2="220" stroke-width="1" />
303
- <line class="line" x1="10" y1="150" x2="80" y2="220" stroke-width="1" />
304
- <line class="line" x1="10" y1="150" x2="80" y2="80" stroke-width="1" />
472
+ ${renderNorthFrame(vm.divisionLabel)}
473
+ ${Array.from({ length: 12 }, (_, i) => {
474
+ const houseNum = i + 1;
475
+ const rashiNum = rashiInHouse(houseNum, lagnaSign);
476
+ const sign = SIGNS_ORDER[rashiNum - 1] ?? 'Aries';
477
+ return renderNorthCell(
478
+ houseNum,
479
+ rashiNum,
480
+ sign,
481
+ vm.placements[sign.toLowerCase()] ?? [],
482
+ houseNum === 1,
483
+ );
484
+ })}
305
485
  `;
306
486
  }
307
487
 
308
- /**
309
- * Render the east-Indian square frame: outer square, both diagonals, and the
310
- * inner diamond joining the four side midpoints. Twelve triangular cells.
311
- */
312
- export function renderEastFrame(): TemplateResult {
488
+ // ---------------------------------------------------------------------------
489
+ // East Indian (Bengali / Maithili): 3x3 underlying grid, 4 edge rectangles +
490
+ // 4 corner cells each split by a diagonal from the outer chart corner to the
491
+ // inner corner of the centre cell. Aries fixed top-centre; signs proceed
492
+ // counter-clockwise. Houses rotate from the Lagna.
493
+ // ---------------------------------------------------------------------------
494
+
495
+ const EAST_CELL = INNER / 3; // 120
496
+
497
+ interface EastCell {
498
+ /** Vertices of the cell polygon, in viewBox units. */
499
+ points: Array<{ x: number; y: number }>;
500
+ /** Visual centroid for label placement. */
501
+ centroid: { x: number; y: number };
502
+ }
503
+
504
+ function eastCells(): Record<string, EastCell> {
505
+ const a = MARGIN; // 20
506
+ const b = MARGIN + EAST_CELL; // 140
507
+ const c = MARGIN + 2 * EAST_CELL; // 260
508
+ const d = VIEW_BOX - MARGIN; // 380
509
+ const aries = [
510
+ { x: b, y: a },
511
+ { x: c, y: a },
512
+ { x: c, y: b },
513
+ { x: b, y: b },
514
+ ];
515
+ const cancer = [
516
+ { x: a, y: b },
517
+ { x: b, y: b },
518
+ { x: b, y: c },
519
+ { x: a, y: c },
520
+ ];
521
+ const libra = [
522
+ { x: b, y: c },
523
+ { x: c, y: c },
524
+ { x: c, y: d },
525
+ { x: b, y: d },
526
+ ];
527
+ const capricorn = [
528
+ { x: c, y: b },
529
+ { x: d, y: b },
530
+ { x: d, y: c },
531
+ { x: c, y: c },
532
+ ];
533
+ const taurus = [
534
+ { x: a, y: a },
535
+ { x: b, y: a },
536
+ { x: b, y: b },
537
+ ];
538
+ const gemini = [
539
+ { x: a, y: a },
540
+ { x: b, y: b },
541
+ { x: a, y: b },
542
+ ];
543
+ const leo = [
544
+ { x: a, y: c },
545
+ { x: b, y: c },
546
+ { x: a, y: d },
547
+ ];
548
+ const virgo = [
549
+ { x: b, y: c },
550
+ { x: b, y: d },
551
+ { x: a, y: d },
552
+ ];
553
+ const scorpio = [
554
+ { x: c, y: c },
555
+ { x: c, y: d },
556
+ { x: d, y: d },
557
+ ];
558
+ const sagittarius = [
559
+ { x: c, y: c },
560
+ { x: d, y: d },
561
+ { x: d, y: c },
562
+ ];
563
+ const aquarius = [
564
+ { x: d, y: a },
565
+ { x: d, y: b },
566
+ { x: c, y: b },
567
+ ];
568
+ const pisces = [
569
+ { x: c, y: a },
570
+ { x: d, y: a },
571
+ { x: c, y: b },
572
+ ];
573
+ const polys = {
574
+ Aries: aries,
575
+ Taurus: taurus,
576
+ Gemini: gemini,
577
+ Cancer: cancer,
578
+ Leo: leo,
579
+ Virgo: virgo,
580
+ Libra: libra,
581
+ Scorpio: scorpio,
582
+ Sagittarius: sagittarius,
583
+ Capricorn: capricorn,
584
+ Aquarius: aquarius,
585
+ Pisces: pisces,
586
+ } as const;
587
+ const out: Record<string, EastCell> = {};
588
+ for (const [sign, points] of Object.entries(polys)) {
589
+ out[sign] = { points: [...points], centroid: centroidOf(points) };
590
+ }
591
+ return out;
592
+ }
593
+
594
+ const EAST_CELLS = eastCells();
595
+
596
+ function renderEastFrame(divisionLabel?: string): TemplateResult {
597
+ const a = MARGIN;
598
+ const b = MARGIN + EAST_CELL;
599
+ const c = MARGIN + 2 * EAST_CELL;
600
+ const d = VIEW_BOX - MARGIN;
313
601
  return svg`
314
- <rect class="line" x="10" y="10" width="280" height="280" stroke-width="1.5" fill="none" />
315
- <line class="line" x1="10" y1="10" x2="290" y2="290" stroke-width="1" />
316
- <line class="line" x1="290" y1="10" x2="10" y2="290" stroke-width="1" />
317
- <polygon class="line" points="150,10 290,150 150,290 10,150" stroke-width="1" fill="none" />
602
+ <rect class="line" x=${a} y=${a} width=${INNER} height=${INNER} stroke-width="1.5" fill="none" />
603
+ <line class="line" x1=${a} y1=${b} x2=${b} y2=${b} stroke-width="1" />
604
+ <line class="line" x1=${c} y1=${b} x2=${d} y2=${b} stroke-width="1" />
605
+ <line class="line" x1=${a} y1=${c} x2=${b} y2=${c} stroke-width="1" />
606
+ <line class="line" x1=${c} y1=${c} x2=${d} y2=${c} stroke-width="1" />
607
+ <line class="line" x1=${b} y1=${a} x2=${b} y2=${b} stroke-width="1" />
608
+ <line class="line" x1=${b} y1=${c} x2=${b} y2=${d} stroke-width="1" />
609
+ <line class="line" x1=${c} y1=${a} x2=${c} y2=${b} stroke-width="1" />
610
+ <line class="line" x1=${c} y1=${c} x2=${c} y2=${d} stroke-width="1" />
611
+ <line class="line" x1=${a} y1=${a} x2=${b} y2=${b} stroke-width="1" />
612
+ <line class="line" x1=${d} y1=${a} x2=${c} y2=${b} stroke-width="1" />
613
+ <line class="line" x1=${d} y1=${d} x2=${c} y2=${c} stroke-width="1" />
614
+ <line class="line" x1=${a} y1=${d} x2=${b} y2=${c} stroke-width="1" />
615
+ ${
616
+ divisionLabel
617
+ ? svg`<text class="centre-label" x=${CENTRE} y=${CENTRE} text-anchor="middle" dominant-baseline="central">${divisionLabel}</text>`
618
+ : nothing
619
+ }
318
620
  `;
319
621
  }
320
622
 
321
- /**
322
- * Render an east-Indian house group. East Indian charts are sign-fixed like
323
- * the south style, so this mirrors `renderSouthHouseGroup` with the east cell
324
- * centers and a smaller line height to fit the triangular cells.
325
- */
326
- export function renderEastHouseGroup(
327
- h: HouseDef,
328
- ): TemplateResult | typeof nothing {
329
- const center = EAST_HOUSE_CENTERS[h.number];
330
- const signPos = EAST_SIGN_POSITIONS[h.number];
331
- if (!center || !signPos) return nothing;
332
- const signAbbr = SIGN_ABBR[h.sign] ?? '';
623
+ function renderEastCell(
624
+ sign: string,
625
+ planets: PlacedGraha[],
626
+ isLagna: boolean,
627
+ houseNum: number,
628
+ ): TemplateResult {
629
+ const cell = EAST_CELLS[sign];
630
+ if (!cell) return svg``;
631
+ const { centroid: cen, points } = cell;
632
+ const signAbbr = SIGN_ABBR[sign] ?? sign.slice(0, 2);
633
+ const polyPoints = points.map((p) => `${p.x},${p.y}`).join(' ');
333
634
  return svg`
334
- <g>
635
+ <g class=${isLagna ? 'cell lagna' : 'cell'}>
335
636
  ${
336
- h.isLagna
337
- ? svg`<circle class="lagna-bg" cx=${center.x} cy=${center.y} r="20" />`
637
+ isLagna
638
+ ? svg`<polygon class="lagna-bg" points=${polyPoints} />`
338
639
  : nothing
339
640
  }
641
+ <text class="sign-text" x=${cen.x} y=${cen.y - 16} text-anchor="middle" dominant-baseline="central">${signAbbr}</text>
340
642
  ${
341
- signAbbr
342
- ? svg`<text class="sign-text" x=${signPos.x} y=${signPos.y} text-anchor="middle" dominant-baseline="central">${signAbbr}</text>`
643
+ houseNum > 0
644
+ ? svg`<text class="house-num" x=${cen.x + 18} y=${cen.y - 16} text-anchor="start" dominant-baseline="central">${houseNum}</text>`
343
645
  : nothing
344
646
  }
345
647
  ${
346
- h.isLagna
347
- ? svg`<text class="lagna-marker" x=${center.x} y=${center.y - 14} text-anchor="middle" dominant-baseline="central">LAGNA</text>`
648
+ isLagna
649
+ ? svg`<text class="lagna-marker" x=${cen.x} y=${cen.y - 30} text-anchor="middle" dominant-baseline="central">Asc</text>`
348
650
  : nothing
349
651
  }
350
- ${renderPlanetStack(h.planets, center.x, center.y + 2, 11)}
652
+ ${planets.length ? renderPlanetStack(planets, sign, cen.x, cen.y + 4, 12) : nothing}
351
653
  </g>
352
654
  `;
353
655
  }
354
656
 
657
+ function renderEastSvg(vm: KundliViewModel): TemplateResult {
658
+ const lagnaKey = vm.lagnaSign.toLowerCase();
659
+ return svg`
660
+ ${renderEastFrame(vm.divisionLabel)}
661
+ ${SIGNS_ORDER.map((sign) =>
662
+ renderEastCell(
663
+ sign,
664
+ vm.placements[sign.toLowerCase()] ?? [],
665
+ sign.toLowerCase() === lagnaKey,
666
+ houseNumberInSign(sign, vm.lagnaSign),
667
+ ),
668
+ )}
669
+ `;
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // Public entry point
674
+ // ---------------------------------------------------------------------------
675
+
355
676
  /**
356
- * Bucket a graha-keyed `meta` map (from a D1 or Dx chart response) into the 12
357
- * sign-indexed houses. Shared by the kundli and divisional chart components so
358
- * both render the same rich per-graha detail. The Lagna entry is consumed only
359
- * to flag the ascendant cell, not rendered as a planet.
677
+ * Render the kundli body for the requested style. Returns the SVG inner
678
+ * content; the caller wraps it in an `<svg>` element with the canonical
679
+ * viewBox `0 0 400 400` and applies its own theming CSS.
360
680
  */
361
- export function buildHousesFromMeta(
362
- meta: Record<
363
- string,
364
- {
365
- graha?: string;
366
- rashi?: string;
367
- longitude?: number;
368
- nakshatra?: { name?: string; pada?: number; lord?: string };
369
- isRetrograde?: boolean;
370
- awastha?: string;
371
- }
372
- >,
373
- ): HouseDef[] {
374
- const byRashi = new Map<string, PlacedGraha[]>();
375
- let lagnaKey = '';
376
- for (const [name, pos] of Object.entries(meta)) {
377
- const rashiKey = (pos?.rashi ?? '').toLowerCase();
378
- if (name === 'Lagna' || pos?.graha === 'Lagna') {
379
- lagnaKey = rashiKey;
380
- continue;
381
- }
382
- if (!rashiKey) continue;
383
- const list = byRashi.get(rashiKey) ?? [];
384
- list.push({
385
- graha: pos.graha ?? name,
386
- longitude: pos.longitude,
387
- nakshatra: pos.nakshatra,
388
- isRetrograde: pos.isRetrograde,
389
- awastha: pos.awastha,
390
- });
391
- byRashi.set(rashiKey, list);
681
+ export function renderKundliSvg(
682
+ vm: KundliViewModel,
683
+ style: ChartStyle,
684
+ ): TemplateResult {
685
+ switch (style) {
686
+ case 'north':
687
+ return renderNorthSvg(vm);
688
+ case 'east':
689
+ return renderEastSvg(vm);
690
+ default:
691
+ return renderSouthSvg(vm);
392
692
  }
393
- return SIGNS_ORDER.map((sign, i) => {
394
- const key = sign.toLowerCase();
395
- return {
396
- number: i + 1,
397
- sign,
398
- planets: byRashi.get(key) ?? [],
399
- isLagna: lagnaKey === key,
400
- };
401
- });
693
+ }
694
+
695
+ /**
696
+ * Render a WAI-ARIA-compliant tablist that lets the end user switch between
697
+ * South / North / East kundli styles at runtime. The hosting component owns
698
+ * the `chartStyle` state; this helper renders the buttons and wires the
699
+ * arrow-key navigation plus click handler.
700
+ *
701
+ * @param active - The currently selected style.
702
+ * @param setStyle - Callback the host component uses to update its state.
703
+ */
704
+ export function renderKundliStyleTablist(
705
+ active: ChartStyle,
706
+ setStyle: (next: ChartStyle) => void,
707
+ ): TemplateResult {
708
+ const onKeyDown = (e: KeyboardEvent) => {
709
+ const idx = CHART_STYLES.findIndex((s) => s.id === active);
710
+ if (e.key === 'ArrowRight') {
711
+ e.preventDefault();
712
+ const next = CHART_STYLES[(idx + 1) % CHART_STYLES.length];
713
+ if (next) setStyle(next.id);
714
+ } else if (e.key === 'ArrowLeft') {
715
+ e.preventDefault();
716
+ const next =
717
+ CHART_STYLES[(idx - 1 + CHART_STYLES.length) % CHART_STYLES.length];
718
+ if (next) setStyle(next.id);
719
+ }
720
+ };
721
+ return html`<div
722
+ class="kundli-tablist"
723
+ role="tablist"
724
+ aria-label="Kundli style"
725
+ @keydown=${onKeyDown}
726
+ >
727
+ ${CHART_STYLES.map(
728
+ (s) => html`<button
729
+ type="button"
730
+ class="kundli-tab"
731
+ role="tab"
732
+ id="kundli-tab-${s.id}"
733
+ aria-selected=${active === s.id ? 'true' : 'false'}
734
+ tabindex=${active === s.id ? '0' : '-1'}
735
+ @click=${() => setStyle(s.id)}
736
+ >
737
+ ${s.label}
738
+ </button>`,
739
+ )}
740
+ </div>`;
402
741
  }