import { css, html, nothing } from 'lit'; import { customElement } from 'lit/decorators.js'; import { planetGlyph } from '../tokens/index.js'; import type { CalculateTransitResponse } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { formatDegreeInSign, longitudeToSignPosition, } from '../utils/degree.js'; import { formatAspectName, formatDateTime, formatNumber, } from '../utils/format.js'; import { frameCaptionStyles, renderFrameCaption } from '../utils/frame.js'; type Transiting = CalculateTransitResponse['transitingPlanets'][number]; type Kaksha = Transiting['kaksha']; /** Kakshas per sign. Fixed by the definition, not by our data: each sign divides into eight stretches of 3 degrees 45 minutes. */ const KAKSHA_COUNT = 8; /** * Gochara: where each graha is transiting now, which natal house it falls in, and the Ashtakavarga-qualified verdict for the exact stretch it occupies. * * @remarks * This renders `POST /vedic-astrology/transit`, the SINGULAR Vedic operation, which is a different response from the Western `calculateTransits` that {@link RoxyTransitsTable} renders. The two are not interchangeable: the Western one carries `transitPlanets` with speed and retrograde flags, this one carries `natalHouse`, `aspectsToNatal` and `kaksha`. * * **Gochara is reckoned from the natal Moon: the house from Janma Rashi leads each row and the house from the Lagna sits beside it**, because a transit chart drawn over the birth chart shows the Lagna reading and a reader comparing the two has to see which number is which. * * **Drishti and the degree-based aspects are both listed and each is labelled**, since Parashari jyotish has no sextile, square or trine and a Vedic card printing only the Western vocabulary would teach a reading the tradition does not make. * * **Kaksha is drawn as a POSITION WITHIN THE SIGN, never as a verdict chip.** The sign says where a graha is; the kaksha says whether the sub-four-degree stretch it currently occupies is one its own Bhinnashtakavarga supports. What a practitioner reads off it is how long until the verdict turns, and a single chip throws that away: a graha two thirds through an unsupported kaksha is a different situation from one that just entered it. The eight-segment bar answers both at a glance. * * The eight kaksha lords run in a fixed order from the start of every sign, and this component deliberately holds NO copy of that order. Only the CURRENT kaksha is labelled, from the `lord` the response carries. A local table would be a second source of truth for data the API owns, and it would sit in a public repo. * * **`bindu` is null for Rahu and Ketu and must render blank, never as an unfavourable verdict.** The nodes have no Bhinnashtakavarga of their own, so there is no bindu to give; treating the absence as a negative would invent a reading the tradition does not make. Since the OpenAPI 3.1 nullability fix the type is `boolean | null`, so a `bindu ? a : b` no longer typechecks its way past the distinction. * * `hide-readings` takes the Key transits section and nothing else. Every graha row stays whole, including the kaksha line: that sentence is a readout of `number`, `lord`, `startDegree`, `endDegree` and `binduCount`, so it is the calculation written out rather than a reading of it. The Key transits section is the reverse, a list of `description` sentences whose one datum, the natal house, is already on the graha row above, so it goes whole rather than leaving its heading behind. */ @customElement('roxy-gochara-table') export class RoxyGocharaTable extends RoxyDataElement { static styles = [ baseStyles, frameCaptionStyles, css` .wrap { background: var(--roxy-surface, #fff); color: var(--roxy-fg, #0a0a0a); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); box-shadow: var(--roxy-shadow-sm); display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; } .head { padding: var(--roxy-space-md, 1rem); border-bottom: 1px solid var(--roxy-border, #e4e4e7); display: grid; gap: var(--roxy-space-xs, 0.25rem); } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); } .sub { margin: 0; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); line-height: var(--roxy-leading-normal, 1.5); } .row { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-xs, 0.25rem); padding: var(--roxy-space-md, 1rem); border-top: 1px solid var(--roxy-border, #e4e4e7); } .row-top { display: flex; align-items: baseline; gap: var(--roxy-space-sm, 0.5rem); flex-wrap: wrap; } .graha { font-weight: var(--roxy-weight-bold, 600); } .glyph { margin-right: 0.35em; color: var(--roxy-muted, #71717a); } .pos { color: var(--roxy-secondary, #475569); font-size: var(--roxy-text-sm, 0.875rem); font-variant-numeric: tabular-nums; } .house { font-size: var(--roxy-text-xs, 0.75rem); padding: 0.1rem 0.45rem; border-radius: var(--roxy-radius-sm, 4px); border: 1px solid var(--roxy-border, #e4e4e7); } /* The Moon reading is the Gochara verdict, so it carries the accent edge; * the Lagna reading stays muted beside it. Colour is the emphasis and the * words carry the distinction, so neither chip depends on the other. */ .house.moon { border-color: var(--roxy-accent, #f59e0b); } .house.lagna { color: var(--roxy-muted, #71717a); } .contact-label { font-weight: var(--roxy-weight-bold, 600); margin-right: 0.35em; } /* Eight segments, one per kaksha, in sign order. The current one is filled * and tinted by its verdict; the rest stay neutral because the response * carries a bindu only for the kaksha actually occupied. Tinting the others * would be inventing seven readings we were not given. */ .kaksha-bar { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; height: 10px; margin: 0.15rem 0; } .seg { background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 60%, transparent); border-radius: 2px; } .seg.here { background: color-mix(in srgb, var(--roxy-muted, #71717a) 45%, transparent); } .seg.here.yes { background: color-mix(in srgb, var(--roxy-success, #16a34a) 55%, transparent); } .seg.here.no { background: color-mix(in srgb, var(--roxy-warning, #f59e0b) 55%, transparent); } .meta { margin: 0; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); line-height: var(--roxy-leading-normal, 1.5); } .num { font-variant-numeric: tabular-nums; } .aspects { margin: 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-secondary, #475569); line-height: var(--roxy-leading-normal, 1.5); } .key { padding: var(--roxy-space-md, 1rem); border-top: 2px solid var(--roxy-border, #e4e4e7); display: grid; gap: var(--roxy-space-sm, 0.5rem); } .key-title { margin: 0; font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.06em; color: var(--roxy-muted, #71717a); } .key-item { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); line-height: var(--roxy-leading-normal, 1.5); } `, ]; protected renderData(d: CalculateTransitResponse) { const planets = d.transitingPlanets ?? []; if (!planets.length) return this.renderEmpty(); const key = d.keyTransits ?? []; // Janma Rashi, named so the house numbers below are readable without a // second request. The response says the reference is the Moon entry. const moonSign = d.natalPlanets?.find((p) => p.name === 'Moon')?.sign; return html`

${this.t('Gochara')}

${this.t( 'Where each graha transits at {{when}}, read against the natal chart of {{birth}}.', { when: formatDateTime(this.effectiveLang(), d.transitDatetime), birth: formatDateTime(this.effectiveLang(), d.birthDatetime), }, )}

${ moonSign ? html`

${this.t('Gochara houses are counted from the natal Moon in {{sign}}.', { sign: moonSign })}

` : nothing } ${renderFrameCaption(this.effectiveLang(), d.frame, this.translator)}
${planets.map((p) => this.renderPlanet(p))}
${ key.length && !this.hideReadings ? html`

${this.t('Key transits')}

${key.map((k) => html`

${k.description}

`)}
` : nothing }
`; } private renderPlanet(p: Transiting) { const glyph = planetGlyph(p.name) ?? ''; const pos = typeof p.longitude === 'number' ? longitudeToSignPosition(p.longitude) : undefined; return html`
${glyph ? html`${glyph}` : nothing}${p.name} ${p.sign}${pos ? html` ${formatDegreeInSign(pos.degree)}` : nothing} ${ typeof p.houseFromMoon === 'number' ? html`${this.t('house {{n}} from the Moon', { n: p.houseFromMoon })}` : nothing } ${ typeof p.natalHouse === 'number' ? html`${this.t('house {{n}} from the Lagna', { n: p.natalHouse })}` : nothing }
${this.renderKaksha(p.kaksha)} ${this.renderContacts(this.t('Drishti'), p.drishtiToNatal)} ${this.renderContacts(this.t('Aspects'), p.aspectsToNatal)}
`; } /** One labelled contact list. Drishti and the degree-based aspects carry the same three fields, so both read through here and the label is what tells them apart. */ private renderContacts( label: string, items: | readonly { natalPlanet: string; aspectType: string; orb: number }[] | undefined, ) { if (!items?.length) return nothing; return html`

${label} ${items .map((a) => typeof a.orb === 'number' ? this.t('{{aspect}} natal {{planet}} ({{orb}}°)', { aspect: formatAspectName({ type: a.aspectType }), planet: a.natalPlanet, orb: formatNumber(this.effectiveLang(), a.orb, 1), }) : this.t('{{aspect}} natal {{planet}}', { aspect: formatAspectName({ type: a.aspectType }), planet: a.natalPlanet, }), ) .join(' · ')}

`; } /** * The eight-segment kaksha bar plus its one-line reading. * * @remarks * `bindu` has three states and each renders differently: `true` supports the transit, `false` does not, and `null` means the graha has no Bhinnashtakavarga at all, which is Rahu and Ketu. The null case gets the neutral segment and NO verdict sentence, the same way the planets table leaves the avastha cells blank for the nodes rather than printing a zero. */ private renderKaksha(k: Kaksha | undefined) { if (!k || typeof k.number !== 'number') return nothing; const here = k.number; const verdict = k.bindu === true ? 'yes' : k.bindu === false ? 'no' : ''; const total = KAKSHA_COUNT; const head = k.lord && typeof k.startDegree === 'number' && typeof k.endDegree === 'number' ? this.t( 'Kaksha {{n}} of {{total}}, ruled by {{graha}}, spanning {{start}}° to {{end}}° of the sign', { n: here, total, graha: k.lord, start: formatNumber(this.effectiveLang(), k.startDegree, 2), end: formatNumber(this.effectiveLang(), k.endDegree, 2), }, ) : k.lord ? this.t('Kaksha {{n}} of {{total}}, ruled by {{graha}}', { n: here, total, graha: k.lord, }) : typeof k.startDegree === 'number' && typeof k.endDegree === 'number' ? this.t( 'Kaksha {{n}} of {{total}}, spanning {{start}}° to {{end}}° of the sign', { n: here, total, start: formatNumber(this.effectiveLang(), k.startDegree, 2), end: formatNumber(this.effectiveLang(), k.endDegree, 2), }, ) : this.t('Kaksha {{n}} of {{total}}', { n: here, total }); const gave = k.bindu === null || k.bindu === undefined ? '' : typeof k.binduCount === 'number' ? k.bindu ? this.t( 'this kaksha lord gave bindu, {{count}} of {{total}} in this sign', { count: k.binduCount, total }, ) : this.t( 'this kaksha lord gave no bindu, {{count}} of {{total}} in this sign', { count: k.binduCount, total }, ) : k.bindu ? this.t('this kaksha lord gave bindu') : this.t('this kaksha lord gave no bindu'); return html`

${gave ? `${head} · ${gave}` : head}

`; } } declare global { interface HTMLElementTagNameMap { 'roxy-gochara-table': RoxyGocharaTable; } }