import { css, html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { PLANET_GLYPH, SIGN_GLYPH } from '../tokens/index.js'; import type { ArabicLotsResponse, AsteroidsResponse, LilithResponse, ProgressionsResponse, SolarArcResponse, } 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 { chevron, disclosureStyles } from '../utils/disclosure.js'; import { formatNumber } from '../utils/format.js'; import { capitalize } from '../utils/string.js'; /** * Union of the position-list Western responses this one editorial table renders. * Each carries an array of bodies in zodiac signs plus a per-body * interpretation; the table discriminates on which array key is present. */ type PositionsResponse = | AsteroidsResponse | LilithResponse | ProgressionsResponse | SolarArcResponse | ArabicLotsResponse; interface Row { label: string; sign: string; degree: number; house?: number; speed?: number; isRetrograde?: boolean; formula?: string; natalLongitude?: number; interpretation?: string; isAngle?: boolean; } interface ViewModel { title: string; badges: Array<{ label: string; value: string }>; summary?: string; rows: Row[]; cols: { house: boolean; motion: boolean; formula: boolean; natal: boolean }; } /** * Editorial positions table for the Western point-list endpoints: asteroids, * Black Moon Lilith, secondary progressions, solar arc directions, and the * Arabic lots. One component, five shapes: it detects the response by its array * key and shows only the columns that response carries (house, motion, formula, * or a natal-to-directed comparison), then lists each body reading below. */ @customElement('roxy-positions-table') export class RoxyPositionsTable extends RoxyDataElement { static styles = [ baseStyles, disclosureStyles, css` .wrap { width: 100%; background: var(--roxy-surface, #fff); color: var(--roxy-fg, #0a0a0a); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-lg, 1.5rem); box-shadow: var(--roxy-shadow-sm); display: grid; gap: var(--roxy-space-md, 1rem); } header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); } .title { font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); margin: 0; color: var(--roxy-primary, #0f172a); } .badges { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); } .badge { padding: 2px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 14%, transparent); color: var(--roxy-fg, #0a0a0a); } .badge b { color: var(--roxy-accent-ink, #b45309); font-weight: 600; } .summary { color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); margin: 0; } .scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } table { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); } caption { text-align: left; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); padding-bottom: var(--roxy-space-xs, 0.25rem); } th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--roxy-border, #e4e4e7); white-space: nowrap; } th { color: var(--roxy-muted, #71717a); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; font-size: var(--roxy-text-xs, 0.75rem); } td.num { text-align: right; font-variant-numeric: tabular-nums; } .body-cell { font-weight: 500; color: var(--roxy-fg, #0a0a0a); } .body-cell .glyph { color: var(--roxy-accent-ink, #b45309); margin-right: 0.35rem; } tr.angle td { color: var(--roxy-secondary, #475569); } .sign { display: inline-flex; align-items: baseline; gap: 0.3rem; } .sign .sg { color: var(--roxy-secondary, #475569); } .retro { color: var(--roxy-danger, #dc2626); font-weight: 600; } .formula { color: var(--roxy-muted, #71717a); font-variant-numeric: tabular-nums; white-space: normal; } .readings h3 { font-size: var(--roxy-text-sm, 0.875rem); font-weight: 600; color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 var(--roxy-space-sm, 0.5rem); } .interp-card { border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); margin-bottom: var(--roxy-space-xs, 0.25rem); } .interp-card summary { cursor: pointer; font-weight: 500; color: var(--roxy-fg, #0a0a0a); display: flex; align-items: center; justify-content: space-between; gap: var(--roxy-space-md, 1rem); } .interp-aside { display: inline-flex; align-items: center; gap: 0.5rem; } .interp-aside small { color: var(--roxy-muted, #71717a); font-weight: 400; } .interp-body { margin-top: var(--roxy-space-xs, 0.25rem); color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); } `, ]; /** Override the auto-derived heading. Empty keeps the per-shape default (e.g. "Asteroids"). */ @property({ type: String }) heading = ''; protected renderEmpty() { return html`
No positions data
`; } protected renderData(data: PositionsResponse) { const vm = this.toViewModel(data); const cols = vm.cols; const readings = vm.rows.filter((r) => r.interpretation); return html`

${this.heading || vm.title}

${ vm.badges.length ? html`
${vm.badges.map((b) => html`${b.label} ${b.value}`)}
` : nothing }
${vm.summary ? html`

${vm.summary}

` : nothing}
${cols.natal ? html`` : nothing} ${cols.house ? html`` : nothing} ${cols.motion ? html`` : nothing} ${cols.formula ? html`` : nothing} ${vm.rows.map((r) => this.renderRow(r, cols))}
${vm.title}
Body PositionNatalHouseMotionFormula
${ readings.length ? html`

Readings

${readings.map((r, i) => this.renderReading(r, i === 0))}
` : nothing }
`; } private renderRow(r: Row, cols: ViewModel['cols']) { const glyph = PLANET_GLYPH[capitalize(r.label)]; return html` ${glyph ? html`${glyph}` : nothing}${r.label} ${this.signCell(r.sign, r.degree)} ${ cols.natal ? html`${r.natalLongitude != null ? this.signFromLongitude(r.natalLongitude) : html`—`}` : nothing } ${ cols.house ? html`${r.house != null ? r.house : html`—`}` : nothing } ${ cols.motion ? html`${ r.speed != null ? html`${formatNumber(r.speed, 3)}°/day${r.isRetrograde ? html` ` : nothing}` : html`—` }` : nothing } ${cols.formula ? html`${r.formula ?? html`—`}` : nothing} `; } private signCell(sign: string, degree: number) { const g = SIGN_GLYPH[capitalize(sign)]; return html`${g ? html`${g}` : nothing}${formatDegreeInSign(degree)} ${sign}`; } private signFromLongitude(longitude: number) { const p = longitudeToSignPosition(longitude); return this.signCell(p.sign, p.degree + p.minute / 60); } private renderReading(r: Row, open: boolean) { const glyph = PLANET_GLYPH[capitalize(r.label)] ?? ''; return html`
${glyph ? html`${glyph} ` : nothing}${r.label} ${r.sign} ${formatDegreeInSign(r.degree)} ${chevron()}
${r.interpretation}
`; } private toViewModel(data: PositionsResponse): ViewModel { if ('asteroids' in data) { return { title: 'Asteroids', badges: data.houseSystem ? [{ label: 'Houses', value: data.houseSystem }] : [], summary: data.summary, cols: { house: true, motion: true, formula: false, natal: false }, rows: data.asteroids.map((a) => ({ label: a.name, sign: a.sign, degree: a.degree, house: a.house, speed: a.speed, isRetrograde: a.isRetrograde, interpretation: a.interpretation, })), }; } if ('lilith' in data) { return { title: 'Black Moon Lilith', badges: data.houseSystem ? [{ label: 'Houses', value: data.houseSystem }] : [], summary: data.summary, cols: { house: true, motion: true, formula: false, natal: false }, rows: data.lilith.map((l) => ({ label: `${capitalize(l.variant)} apogee`, sign: l.sign, degree: l.degree, house: l.house, speed: l.speed, isRetrograde: l.isRetrograde, interpretation: l.interpretation, })), }; } if ('directed' in data) { return { title: 'Solar arc directions', badges: [ { label: 'Arc', value: `${formatNumber(data.solarArc, 2)}°` }, { label: 'Directed to', value: data.targetDate }, ], summary: data.summary, cols: { house: false, motion: false, formula: false, natal: true }, rows: data.directed.map((d) => ({ label: d.name, sign: d.sign, degree: d.degree, natalLongitude: d.natalLongitude, interpretation: d.interpretation, })), }; } if ('lots' in data) { return { title: 'Arabic lots', badges: data.sect ? [{ label: 'Sect', value: capitalize(data.sect) }] : [], summary: data.summary, cols: { house: false, motion: false, formula: true, natal: false }, rows: data.lots.map((l) => ({ label: l.name, sign: l.sign, degree: l.degree, formula: l.formula, interpretation: l.interpretation, })), }; } // Secondary progressions: planets plus the progressed angles. const angleRows: Row[] = []; if (data.ascendant) { angleRows.push({ label: 'Ascendant', sign: data.ascendant.sign, degree: data.ascendant.degree, isAngle: true, }); } if (data.midheaven) { angleRows.push({ label: 'Midheaven', sign: data.midheaven.sign, degree: data.midheaven.degree, isAngle: true, }); } return { title: 'Secondary progressions', badges: [ { label: 'Progressed to', value: data.targetDate }, { label: 'Elapsed', value: `${formatNumber(data.elapsedYears, 1)} yrs`, }, ], summary: data.summary, cols: { house: true, motion: true, formula: false, natal: false }, rows: [ ...angleRows, ...data.planets.map((p) => ({ label: p.name, sign: p.sign, degree: p.degree, house: p.house, speed: p.speed, isRetrograde: p.isRetrograde, interpretation: p.interpretation, })), ], }; } } declare global { interface HTMLElementTagNameMap { 'roxy-positions-table': RoxyPositionsTable; } }