import type { TemplateResult } from 'lit'; import { html, nothing, svg } from 'lit'; import type { ChromeString } from '../i18n/chrome-strings.js'; import { planetAbbr, SIGNS_ORDER, signAbbr } from '../tokens/index.js'; import { longitudeToSignPosition } from './degree.js'; import type { Translate } from './hd-reading.js'; import { capitalize } from './string.js'; import { renderTablist } from './tablist.js'; /** * Canonical viewBox geometry for every kundli style. The chart is drawn into a * 360-unit square centred in a 400-unit viewBox, leaving a 20-unit gutter for * outer labels. All coordinates below are derived from these constants; change * them and every cell relocates correctly. * * @remarks SVG is vector-only and scales without raster loss, so the chart * remains crisp from a phone screen to a wall projector. Hosts size the chart * by setting `width` on the surrounding container; the SVG keeps a 1:1 aspect * ratio via the viewBox. */ const VIEW_BOX = 400; const MARGIN = 20; const INNER = VIEW_BOX - 2 * MARGIN; // 360 const CENTRE = VIEW_BOX / 2; // 200 /** * Lowercase rashi key (`"aries"`) to canonical title-cased sign name (`"Aries"`). * Bridges API lowercase rashi strings to the SIGNS_ORDER tokens used everywhere * else in the render. */ const RASHI_TO_SIGN: Record = Object.fromEntries( SIGNS_ORDER.map((s) => [s.toLowerCase(), s] as const), ); /** * A graha placed inside a kundli cell. Render-only view model fed from a * `meta` map on the API response. Carries enough detail to draw a compact * in-cell label (abbreviation, whole degree, retrograde mark) and a rich SVG * `` tooltip (exact position, nakshatra, pada, avastha). */ export interface PlacedGraha { graha: string; longitude?: number; nakshatra?: { name?: string; pada?: number; lord?: string }; isRetrograde?: boolean; awastha?: string; /** Deeptadi avastha, the nine dispositional states. Rendered as the response sends it, like {@link PlacedGraha.awastha}. */ deeptadi?: string; /** Within its combustion orb of the Sun (astangata). */ combust?: boolean; /** Graha yuddha: the opponent within one degree, and which of the pair won. */ war?: { opponent: string; winner?: string }; } /** The two chart-wide state lists a D1 response carries beside `meta`. Divisional charts have neither, so both are optional. */ export interface GrahaStates { combustion?: ReadonlyArray<{ planet?: string }>; planetaryWar?: ReadonlyArray<{ planet1?: string; planet2?: string; winner?: string; }>; } /** * Unified view model used by every kundli style. Caller passes a graha-keyed * `meta` map (from `/vedic-astrology/birth-chart`, `/divisional-chart`, or * `/navamsa`) through {@link toKundliViewModel} to produce this shape. * * `placements` is keyed by lowercase rashi name (`"aries"`, `"taurus"`, ...) * so the sign-fixed styles can index directly. The Lagna entry is not * counted as a planet; it only flags the ascendant cell. */ export interface KundliViewModel { lagnaSign: string; placements: Record<string, PlacedGraha[]>; divisionLabel?: string; } /** * Kundli regional styles. Sign-fixed (south, east) and house-fixed (north). * Exposed so consumers can type their own `chart-style` attribute reflection. */ export type ChartStyle = 'south' | 'north' | 'east'; /** Tab order for the style switcher, each id beside the English source its label is looked up by. One ordered list rather than an order and a lookup, so a style cannot exist in one and not the other, and `source` is typed {@link ChromeString} so the compiler rejects a tab no catalogue carries. */ const CHART_STYLES: ReadonlyArray<{ id: ChartStyle; source: ChromeString }> = [ { id: 'north', source: 'North' }, { id: 'south', source: 'South' }, { id: 'east', source: 'East' }, ]; /** Superscript state marks on a graha label. A cell is small, so each state that changes how a graha is READ gets one character and the tooltip carries the words. */ const RETRO_MARK = 'ʳ'; const COMBUST_MARK = 'ᶜ'; const WAR_MARK = 'ʷ'; /** Position of a sign in the zodiac order, or -1. Takes a plain string because it reads API values, and widens the literal tuple to match so the lookup is a plain `indexOf`. */ function signIndex(sign: string): number { return (SIGNS_ORDER as readonly string[]).indexOf(sign); } /** * True when the placed graha's longitude maps to a sign other than the cell * it occupies. The API preserves the D1 sidereal longitude on every chart, so * inside a D2..D60 cell that longitude refers to the D1 sign, not the cell's * divisional sign. In that case the degree-within-sign is not meaningful and * must be hidden from the in-cell label. */ function isDivisionalPlacement(p: PlacedGraha, cellSign: string): boolean { if (typeof p.longitude !== 'number' || !Number.isFinite(p.longitude)) { return false; } return ( longitudeToSignPosition(p.longitude).sign.toLowerCase() !== cellSign.toLowerCase() ); } /** * Compact in-cell graha label: abbreviation, optional whole-degree, retrograde * mark. The degree is shown only when the longitude actually maps to the cell * the graha is rendered in (the D1 case); divisional placements show the * abbreviation alone since the API longitude refers to D1, not the divisional * sign. A body the library has no abbreviation for falls back to its full * name: the cell is the only place that name appears, so it must stay * visibly wrong rather than a fabricated two-letter code. */ function grahaLabel(p: PlacedGraha, cellSign: string): string { const abbr = planetAbbr(p.graha) ?? p.graha; const retro = `${p.isRetrograde ? RETRO_MARK : ''}${p.combust ? COMBUST_MARK : ''}${p.war ? WAR_MARK : ''}`; if ( typeof p.longitude !== 'number' || !Number.isFinite(p.longitude) || isDivisionalPlacement(p, cellSign) ) { return `${abbr}${retro}`; } const { degree } = longitudeToSignPosition(p.longitude); return `${abbr} ${degree}°${retro}`; } /** * Full-detail tooltip surfaced via the SVG `<title>` for each planet label. * Includes planet name, the divisional placement (when the longitude does not * match the cell, the cell's rashi is preferred), the exact D1 longitude as * the original reference, nakshatra and pada, avastha, and the retrograde * flag. Surfaces on hover or long-press without crowding the cell. */ function grahaTitle(p: PlacedGraha, cellSign: string, t: Translate): string { const parts: string[] = [capitalize(p.graha)]; const divisional = isDivisionalPlacement(p, cellSign); if (divisional) { parts.push(t('in {{sign}}', { sign: cellSign })); } if (typeof p.longitude === 'number' && Number.isFinite(p.longitude)) { const sp = longitudeToSignPosition(p.longitude); const minute = String(sp.minute).padStart(2, '0'); parts.push( divisional ? `D1: ${sp.degree}°${minute}' ${sp.sign}` : `${sp.degree}°${minute}' ${sp.sign}`, ); } if (p.nakshatra?.name) { const pada = p.nakshatra.pada ? ` ${t('pada {{n}}', { n: p.nakshatra.pada })}` : ''; parts.push(`${p.nakshatra.name}${pada}`); } // The nakshatra lord is what a dasha is read from, so a chart that names the // nakshatra and withholds its lord stops one step short of the reading. if (p.nakshatra?.lord) { parts.push(t('nakshatra lord {{graha}}', { graha: p.nakshatra.lord })); } if (p.awastha) parts.push(p.awastha); if (p.deeptadi) parts.push(p.deeptadi); if (p.combust) parts.push(t('combust')); if (p.war) { parts.push( p.war.winner ? t('planetary war with {{graha}}, won by {{winner}}', { graha: p.war.opponent, winner: p.war.winner, }) : t('planetary war with {{graha}}', { graha: p.war.opponent }), ); } if (p.isRetrograde) parts.push(t('retrograde')); return parts.join(' · '); } /** * Render a vertically centred stack of planet labels at `(cx, baseY)`, one * line per planet, with an SVG `<title>` per line carrying the full tooltip. * The stack auto-centres on `baseY` regardless of count so a 1-planet cell * and a 5-planet cell both look intentional. */ function renderPlanetStack( planets: PlacedGraha[], cellSign: string, cx: number, baseY: number, lineHeight: number, t: Translate, ): TemplateResult[] { const startY = baseY - ((planets.length - 1) * lineHeight) / 2; return planets.map((p, j) => { const yPos = startY + j * lineHeight; return svg`<text class="planet-text" x=${cx} y=${yPos} text-anchor="middle" dominant-baseline="central">${grahaLabel( p, cellSign, )}<title>${grahaTitle(p, cellSign, t)}`; }); } /** * Bucket a graha-keyed `meta` map (D1 birth chart or D2..D60 divisional * chart) into the unified {@link KundliViewModel} the renderer consumes. The * Lagna entry is recognised by `graha === 'Lagna'` (or key `"Lagna"`) and * sets `lagnaSign`; it is not bucketed as a placed planet. * * @param meta - Graha-keyed map; missing rashi entries are skipped. * @param options - `divisionLabel` titles the chart centre. `lagnaOverride` is a rashi/sign name (case-insensitive, e.g. `"cancer"`) replacing the `meta.Lagna`-derived ascendant, which drives the Chandra Lagna and other reference-point views: the `meta` of a `/birth-chart` response always carries the Janma Lagna as its `Lagna` key, so this is the only way to pivot the houses without a second request, and it is ignored when it does not resolve to a known sign. `states` folds the chart-wide combustion and graha-yuddha lists onto the grahas they name, because both arrive beside `meta` rather than inside it and a cell can only mark what its own entry carries. */ export function toKundliViewModel( meta: Record< string, { graha?: string; rashi?: string; longitude?: number; nakshatra?: { name?: string; pada?: number; lord?: string }; isRetrograde?: boolean; awastha?: string; deeptadi?: string; } >, options: { divisionLabel?: string; lagnaOverride?: string; states?: GrahaStates; } = {}, ): KundliViewModel { const { divisionLabel, lagnaOverride, states } = options; const combust = new Set( (states?.combustion ?? []).map((c) => (c.planet ?? '').toLowerCase()), ); const wars = new Map(); for (const w of states?.planetaryWar ?? []) { if (!w.planet1 || !w.planet2) continue; wars.set(w.planet1.toLowerCase(), { opponent: w.planet2, winner: w.winner, }); wars.set(w.planet2.toLowerCase(), { opponent: w.planet1, winner: w.winner, }); } const placements: Record = {}; for (const sign of SIGNS_ORDER) placements[sign.toLowerCase()] = []; const override = lagnaOverride ? (RASHI_TO_SIGN[lagnaOverride.toLowerCase()] ?? '') : ''; let lagnaSign = override; for (const [name, pos] of Object.entries(meta ?? {})) { const rashiKey = (pos?.rashi ?? '').toLowerCase(); if (name === 'Lagna' || pos?.graha === 'Lagna') { // An explicit override pins the ascendant; otherwise the Janma Lagna // from meta is the reference point. if (!override) lagnaSign = RASHI_TO_SIGN[rashiKey] ?? ''; continue; } if (!rashiKey || !(rashiKey in placements)) continue; const graha = pos.graha ?? name; const key = graha.toLowerCase(); placements[rashiKey]?.push({ graha, longitude: pos.longitude, nakshatra: pos.nakshatra, isRetrograde: pos.isRetrograde, awastha: pos.awastha, deeptadi: pos.deeptadi, combust: combust.has(key) || undefined, war: wars.get(key), }); } return { lagnaSign, placements, divisionLabel }; } // --------------------------------------------------------------------------- // South Indian: 4x4 grid with central 2x2 hollow. Signs are FIXED to cells // (Pisces top-left corner, clockwise); houses rotate from the Lagna cell. // --------------------------------------------------------------------------- const SOUTH_CELL = INNER / 4; // 90 /** * Sign-to-cell column/row in the South Indian fixed-sign grid. Pisces sits in * the top-left corner and the remaining signs proceed clockwise around the * 12 perimeter cells. (col, row) origin is the chart top-left. */ const SOUTH_CELL_GRID: Record = { Pisces: { col: 0, row: 0 }, Aries: { col: 1, row: 0 }, Taurus: { col: 2, row: 0 }, Gemini: { col: 3, row: 0 }, Cancer: { col: 3, row: 1 }, Leo: { col: 3, row: 2 }, Virgo: { col: 3, row: 3 }, Libra: { col: 2, row: 3 }, Scorpio: { col: 1, row: 3 }, Sagittarius: { col: 0, row: 3 }, Capricorn: { col: 0, row: 2 }, Aquarius: { col: 0, row: 1 }, }; function southCellRect(sign: string): { x: number; y: number; w: number; h: number; } { const g = SOUTH_CELL_GRID[sign] ?? { col: 0, row: 0 }; return { x: MARGIN + g.col * SOUTH_CELL, y: MARGIN + g.row * SOUTH_CELL, w: SOUTH_CELL, h: SOUTH_CELL, }; } /** * South Indian frame: outer square, the two full-span grid lines, and the * partial inner lines that bound the central 2x2 hollow on each edge. */ function renderSouthFrame(divisionLabel?: string): TemplateResult { const a = MARGIN; const b = MARGIN + SOUTH_CELL; // 110 const c = MARGIN + 2 * SOUTH_CELL; // 200 const d = MARGIN + 3 * SOUTH_CELL; // 290 const e = VIEW_BOX - MARGIN; // 380 return svg` ${ divisionLabel ? svg`${divisionLabel}` : nothing } `; } /** * House number for a given sign relative to a Lagna sign. House 1 is the * Lagna cell; subsequent houses follow the zodiac in order. Returns 0 when * the Lagna sign is unknown so the caller can skip rendering the badge. */ function houseNumberInSign(sign: string, lagnaSign: string): number { const lagnaIdx = signIndex(lagnaSign); const signIdx = signIndex(sign); if (lagnaIdx === -1 || signIdx === -1) return 0; return ((signIdx - lagnaIdx + 12) % 12) + 1; } function renderSouthCell( sign: string, planets: PlacedGraha[], isLagna: boolean, houseNum: number, t: Translate, ): TemplateResult { const r = southCellRect(sign); const cx = r.x + r.w / 2; const cy = r.y + r.h / 2; const sAbbr = signAbbr(sign) ?? sign; // Inset the Lagna diagonal so it does not collide with the chart frame on // corner cells (Pisces, Gemini, Virgo, Sagittarius) or with the sign label // in the top-left of every cell. const slashInset = 14; return svg` ${ isLagna ? svg` ` : nothing } ${sAbbr} ${ houseNum > 0 ? svg`${houseNum}` : nothing } ${ isLagna ? svg`${t('ASC')}` : nothing } ${planets.length ? renderPlanetStack(planets, sign, cx, cy + 4, 14, t) : nothing} `; } function renderSouthSvg(vm: KundliViewModel, t: Translate): TemplateResult { const lagnaKey = vm.lagnaSign.toLowerCase(); return svg` ${renderSouthFrame(vm.divisionLabel)} ${SIGNS_ORDER.map((sign) => renderSouthCell( sign, vm.placements[sign.toLowerCase()] ?? [], sign.toLowerCase() === lagnaKey, houseNumberInSign(sign, vm.lagnaSign), t, ), )} `; } // --------------------------------------------------------------------------- // North Indian: outer square + inscribed midpoint diamond + both outer // diagonals. 12 cells: 4 cardinal diamonds + 8 corner triangles. Houses are // FIXED (H1 always top-centre); signs rotate from the Lagna sign. // --------------------------------------------------------------------------- const NORTH_VERTICES = { tl: { x: MARGIN, y: MARGIN }, tr: { x: VIEW_BOX - MARGIN, y: MARGIN }, br: { x: VIEW_BOX - MARGIN, y: VIEW_BOX - MARGIN }, bl: { x: MARGIN, y: VIEW_BOX - MARGIN }, top: { x: CENTRE, y: MARGIN }, right: { x: VIEW_BOX - MARGIN, y: CENTRE }, bottom: { x: CENTRE, y: VIEW_BOX - MARGIN }, left: { x: MARGIN, y: CENTRE }, tlMid: { x: CENTRE - INNER / 4, y: CENTRE - INNER / 4 }, trMid: { x: CENTRE + INNER / 4, y: CENTRE - INNER / 4 }, brMid: { x: CENTRE + INNER / 4, y: CENTRE + INNER / 4 }, blMid: { x: CENTRE - INNER / 4, y: CENTRE + INNER / 4 }, } as const; /** * Centroid (geometric mean) of an arbitrary set of polygon vertices. Used by * every cell that needs a label-anchor point; defining it once keeps the * North diamond and East triangle math identical. */ function centroidOf(pts: Array<{ x: number; y: number }>): { x: number; y: number; } { const x = pts.reduce((s, p) => s + p.x, 0) / pts.length; const y = pts.reduce((s, p) => s + p.y, 0) / pts.length; return { x, y }; } /** * House centres for the North Indian diamond. Numbered 1..12 counter-clockwise * from the top diamond (H1 is always the ascendant cell). Centroids derived * from the canonical geometry above; do not edit by eye, recompute if you * change `VIEW_BOX` or `MARGIN`. */ const NORTH_HOUSE_CENTERS: Record = { 1: { x: CENTRE, y: NORTH_VERTICES.tlMid.y }, 2: centroidOf([NORTH_VERTICES.tl, NORTH_VERTICES.top, NORTH_VERTICES.tlMid]), 3: centroidOf([NORTH_VERTICES.tl, NORTH_VERTICES.left, NORTH_VERTICES.tlMid]), 4: { x: NORTH_VERTICES.tlMid.x, y: CENTRE }, 5: centroidOf([NORTH_VERTICES.bl, NORTH_VERTICES.left, NORTH_VERTICES.blMid]), 6: centroidOf([ NORTH_VERTICES.bl, NORTH_VERTICES.bottom, NORTH_VERTICES.blMid, ]), 7: { x: CENTRE, y: NORTH_VERTICES.blMid.y }, 8: centroidOf([ NORTH_VERTICES.br, NORTH_VERTICES.bottom, NORTH_VERTICES.brMid, ]), 9: centroidOf([ NORTH_VERTICES.br, NORTH_VERTICES.right, NORTH_VERTICES.brMid, ]), 10: { x: NORTH_VERTICES.brMid.x, y: CENTRE }, 11: centroidOf([ NORTH_VERTICES.tr, NORTH_VERTICES.right, NORTH_VERTICES.trMid, ]), 12: centroidOf([NORTH_VERTICES.tr, NORTH_VERTICES.top, NORTH_VERTICES.trMid]), }; function renderNorthFrame(divisionLabel?: string): TemplateResult { const { tl, tr, br, bl, top, right, bottom, left } = NORTH_VERTICES; return svg` ${ divisionLabel ? svg`${divisionLabel}` : nothing } `; } function renderNorthCell( houseNum: number, rashiNum: number, sign: string, planets: PlacedGraha[], isLagna: boolean, t: Translate, ): TemplateResult { const c = NORTH_HOUSE_CENTERS[houseNum]; if (!c) return svg``; // Tight cells (H2/3/5/6/8/9/11/12 corner triangles) clip the rasi number // when it sits too high above the centroid. Clamp the upward offset based // on the cell's distance from the chart vertical centre so the label // always stays comfortably inside its triangle or diamond. const rashiOffsetY = Math.min(14, Math.abs(c.y - CENTRE) * 0.45 + 6); const ascOffsetY = rashiOffsetY + 12; // North cells carry only a rasi number by convention. The ascendant also // names its sign so the reader can see which sign rises without translating // the number; other cells stay number-only. const rashiLabel = isLagna ? `${rashiNum} · ${signAbbr(sign) ?? sign}` : `${rashiNum}`; return svg` ${rashiLabel} ${ isLagna ? svg`${t('ASC')}` : nothing } ${planets.length ? renderPlanetStack(planets, sign, c.x, c.y + 8, 12, t) : nothing} `; } function renderNorthSvg(vm: KundliViewModel, t: Translate): TemplateResult { return svg` ${renderNorthFrame(vm.divisionLabel)} ${SIGNS_ORDER.map((sign, i) => { // Walking the signs rather than the houses answers both questions the // cell needs from the one house count the sign-fixed styles already use: // which house this sign falls in, and therefore which rashi that house // carries. Cells are positioned by house, so emission order is free. const houseNum = houseNumberInSign(sign, vm.lagnaSign); return renderNorthCell( houseNum, i + 1, sign, vm.placements[sign.toLowerCase()] ?? [], houseNum === 1, t, ); })} `; } // --------------------------------------------------------------------------- // East Indian (Bengali / Maithili): 3x3 underlying grid, 4 edge rectangles + // 4 corner cells each split by a diagonal from the outer chart corner to the // inner corner of the centre cell. Aries fixed top-centre; signs proceed // counter-clockwise. Houses rotate from the Lagna. // --------------------------------------------------------------------------- const EAST_CELL = INNER / 3; // 120 interface EastCell { /** Vertices of the cell polygon, in viewBox units. */ points: Array<{ x: number; y: number }>; /** Visual centroid for label placement. */ centroid: { x: number; y: number }; } function eastCells(): Record { const a = MARGIN; // 20 const b = MARGIN + EAST_CELL; // 140 const c = MARGIN + 2 * EAST_CELL; // 260 const d = VIEW_BOX - MARGIN; // 380 const aries = [ { x: b, y: a }, { x: c, y: a }, { x: c, y: b }, { x: b, y: b }, ]; const cancer = [ { x: a, y: b }, { x: b, y: b }, { x: b, y: c }, { x: a, y: c }, ]; const libra = [ { x: b, y: c }, { x: c, y: c }, { x: c, y: d }, { x: b, y: d }, ]; const capricorn = [ { x: c, y: b }, { x: d, y: b }, { x: d, y: c }, { x: c, y: c }, ]; const taurus = [ { x: a, y: a }, { x: b, y: a }, { x: b, y: b }, ]; const gemini = [ { x: a, y: a }, { x: b, y: b }, { x: a, y: b }, ]; const leo = [ { x: a, y: c }, { x: b, y: c }, { x: a, y: d }, ]; const virgo = [ { x: b, y: c }, { x: b, y: d }, { x: a, y: d }, ]; const scorpio = [ { x: c, y: c }, { x: c, y: d }, { x: d, y: d }, ]; const sagittarius = [ { x: c, y: c }, { x: d, y: d }, { x: d, y: c }, ]; const aquarius = [ { x: d, y: a }, { x: d, y: b }, { x: c, y: b }, ]; const pisces = [ { x: c, y: a }, { x: d, y: a }, { x: c, y: b }, ]; const polys = { Aries: aries, Taurus: taurus, Gemini: gemini, Cancer: cancer, Leo: leo, Virgo: virgo, Libra: libra, Scorpio: scorpio, Sagittarius: sagittarius, Capricorn: capricorn, Aquarius: aquarius, Pisces: pisces, } as const; const out: Record = {}; for (const [sign, points] of Object.entries(polys)) { out[sign] = { points: [...points], centroid: centroidOf(points) }; } return out; } const EAST_CELLS = eastCells(); function renderEastFrame(divisionLabel?: string): TemplateResult { const a = MARGIN; const b = MARGIN + EAST_CELL; const c = MARGIN + 2 * EAST_CELL; const d = VIEW_BOX - MARGIN; return svg` ${ divisionLabel ? svg`${divisionLabel}` : nothing } `; } function renderEastCell( sign: string, planets: PlacedGraha[], isLagna: boolean, houseNum: number, t: Translate, ): TemplateResult { const cell = EAST_CELLS[sign]; if (!cell) return svg``; const { centroid: cen, points } = cell; const sAbbr = signAbbr(sign) ?? sign; const polyPoints = points.map((p) => `${p.x},${p.y}`).join(' '); return svg` ${ isLagna ? svg`` : nothing } ${sAbbr} ${ houseNum > 0 ? svg`${houseNum}` : nothing } ${ isLagna ? svg`${t('ASC')}` : nothing } ${planets.length ? renderPlanetStack(planets, sign, cen.x, cen.y + 4, 12, t) : nothing} `; } function renderEastSvg(vm: KundliViewModel, t: Translate): TemplateResult { const lagnaKey = vm.lagnaSign.toLowerCase(); return svg` ${renderEastFrame(vm.divisionLabel)} ${SIGNS_ORDER.map((sign) => renderEastCell( sign, vm.placements[sign.toLowerCase()] ?? [], sign.toLowerCase() === lagnaKey, houseNumberInSign(sign, vm.lagnaSign), t, ), )} `; } // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- /** True when the chart carries an ascendant the house-fixed north layout can be anchored to. The one place that question is answered, so the renderer and the caller showing {@link renderMissingLagnaNote} can never disagree about it. */ export function hasLagna(vm: KundliViewModel): boolean { return signIndex(vm.lagnaSign) !== -1; } /** Key for each mark, listing only the ones this chart actually uses so a legend never explains a symbol nobody can see. */ function renderGrahaMarkLegend( vm: KundliViewModel, t: Translate, ): TemplateResult | typeof nothing { const placed = Object.values(vm.placements).flat(); const keys: string[] = []; if (placed.some((p) => p.isRetrograde)) { keys.push(`${RETRO_MARK} ${t('retrograde')}`); } if (placed.some((p) => p.combust)) { keys.push(`${COMBUST_MARK} ${t('combust')}`); } if (placed.some((p) => p.war)) { keys.push(`${WAR_MARK} ${t('planetary war')}`); } if (keys.length === 0) return nothing; return html`

${keys.join(' · ')}

`; } /** One line saying a chart arrived with no ascendant, so houses go unnumbered and the sign-fixed layout is drawn. */ function renderMissingLagnaNote(t: Translate): TemplateResult { return html`

${t('No ascendant in this chart, so the houses are not numbered.')}

`; } /** * Every caption that belongs under a kundli: why the layout changed when no ascendant arrived, and the key to the state marks the cells carry. * * @remarks * One entry point rather than two, because both callers need the identical set and a caller that rendered only one of them would explain the marks on a chart whose layout had silently changed, or the reverse. Uses the shared frame-caption class, so a component already showing the sidereal frame needs no extra styles. */ export function renderKundliCaptions( vm: KundliViewModel, t: Translate, ): TemplateResult[] { const out: TemplateResult[] = []; if (!hasLagna(vm)) out.push(renderMissingLagnaNote(t)); const legend = renderGrahaMarkLegend(vm, t); if (legend !== nothing) out.push(legend); return out; } /** * Render the kundli body for the requested style. Returns the SVG inner * content; the caller wraps it in an `` element with the canonical * viewBox `0 0 400 400` and applies its own theming CSS. */ export function renderKundliSvg( vm: KundliViewModel, style: ChartStyle, t: Translate, ): TemplateResult { // The north layout is house-fixed: every cell IS a house and the rashi it // carries is counted from the Lagna. With no ascendant there is nothing to // anchor that rotation to, and assuming one draws a complete, plausible chart // of a different nativity. The sign-fixed layout needs no ascendant, so it is // drawn instead and {@link renderMissingLagnaNote} says why. if (style === 'north' && !hasLagna(vm)) return renderSouthSvg(vm, t); switch (style) { case 'north': return renderNorthSvg(vm, t); case 'east': return renderEastSvg(vm, t); default: return renderSouthSvg(vm, t); } } /** * Render a WAI-ARIA-compliant tablist that lets the end user switch between * South / North / East kundli styles at runtime. The hosting component owns * the `chartStyle` state; this helper renders the buttons and wires the * arrow-key navigation plus click handler. * * @param active - The currently selected style. * @param setStyle - Callback the host component uses to update its state. */ export function renderKundliStyleTablist( active: ChartStyle, setStyle: (next: ChartStyle) => void, t: Translate, ): TemplateResult { return renderTablist({ items: CHART_STYLES.map(({ id, source }) => ({ id, label: t(source) })), active, onSelect: setStyle, label: t('Kundli style'), idPrefix: 'kundli', // The chart SVG below is the panel these tabs swap. Callers MUST wrap it in // `
`, otherwise the buttons // claim role=tab while governing nothing, which is what a screen reader // announces as a broken tab set. controls: true, }); }