import { css, html, LitElement, nothing, svg } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { PLANET_GLYPH, SIGN_GLYPH, SIGNS_ORDER } from '../tokens/index.js'; import type { NatalChartResponse } from '../types/index.js'; import { baseStyles } from '../utils/base-styles.js'; import { polarToCartesian } from '../utils/degree.js'; import { ASPECT_CLASS, formatNumber, normalizeAspect, } from '../utils/format.js'; import { capitalize } from '../utils/string.js'; type PlanetEntry = NatalChartResponse['planets'][number]; type AspectEntry = NatalChartResponse['aspects'][number]; const SIZE = 420; const CENTER = SIZE / 2; const OUTER_R = 164; const SIGN_R = 146; const HOUSE_R = 120; const PLANET_R = 96; const ANGLE_TICK_R = 178; const ANGLE_LABEL_R = 196; /** * Western natal chart wheel. Renders the 12 zodiac signs, 12 houses, planet * markers, and aspect lines from a /astrology/natal-chart response. */ @customElement('roxy-natal-chart') export class RoxyNatalChart extends LitElement { static styles = [ baseStyles, css` .wrap { width: 100%; display: grid; gap: 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); } .meta { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-sm, 0.875rem); } svg { display: block; width: 100%; max-width: 360px; height: auto; margin: 0 auto; } .wheel-line { fill: none; stroke: var(--roxy-border, #e4e4e7); } .sign-glyph { fill: var(--roxy-secondary, #475569); font-size: 14px; font-family: var(--roxy-font-sans); } .planet-glyph { fill: var(--roxy-accent, #f59e0b); font-size: 14px; font-weight: 600; font-family: var(--roxy-font-sans); } .house-num { fill: var(--roxy-muted, #71717a); font-size: 9px; font-family: var(--roxy-font-sans); } .aspect { stroke-width: 0.8; fill: none; opacity: 0.55; } .aspect-trine, .aspect-sextile { stroke: var(--roxy-success, #16a34a); } .aspect-square, .aspect-opposition { stroke: var(--roxy-danger, #dc2626); } .aspect-conjunction { stroke: var(--roxy-accent-fg, #b45309); } .aspect-other { stroke: var(--roxy-muted, #71717a); opacity: 0.4; } .angle-marker { fill: var(--roxy-accent-fg, #b45309); font-size: 10px; font-weight: 700; font-family: var(--roxy-font-sans); letter-spacing: 0.04em; } .angle-tick { stroke: var(--roxy-accent-fg, #b45309); stroke-width: 1.5; } .legend { font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); display: flex; flex-wrap: wrap; gap: var(--roxy-space-md, 1rem); } .legend-swatch { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .details { margin-top: var(--roxy-space-md, 1rem); } .pill-row { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); margin-bottom: var(--roxy-space-xs, 0.25rem); } .pill { padding: 2px 8px; border-radius: var(--roxy-radius-sm, 4px); font-size: var(--roxy-text-xs, 0.75rem); background: color-mix(in srgb, var(--roxy-fg, #0f172a) 8%, transparent); color: var(--roxy-fg, #0f172a); } .pill--success { background: color-mix(in srgb, var(--roxy-success, #16a34a) 15%, transparent); color: var(--roxy-success, #16a34a); } .pill--danger { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 15%, transparent); color: var(--roxy-danger, #dc2626); } .pill--muted { background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 60%, transparent); color: var(--roxy-fg, #0a0a0a); } .summary { color: var(--roxy-fg, #0f172a); font-size: var(--roxy-text-sm, 0.875rem); margin: var(--roxy-space-md, 1rem) 0; } .dist-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--roxy-space-md, 1rem); } @container (max-width: 639px) { .dist-grid { grid-template-columns: 1fr; } } .dist-section h3 { font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-muted, #71717a); margin: 0 0 var(--roxy-space-xs, 0.25rem); text-transform: uppercase; letter-spacing: 0.05em; } .dist-row { display: grid; grid-template-columns: 4rem 1fr 1.5rem; align-items: center; gap: var(--roxy-space-xs, 0.25rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-fg, #0f172a); margin-bottom: 4px; } .dist-bar { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 20%, transparent); height: 6px; border-radius: 3px; } .dist-bar > span { display: block; height: 100%; background: var(--roxy-accent, #f59e0b); border-radius: 3px; } .interpretations { margin-top: var(--roxy-space-md, 1rem); } .interpretations 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, #0f172a); } .interp-card summary small { color: var(--roxy-muted, #71717a); margin-left: 0.5em; font-weight: 400; } .interp-body { margin-top: var(--roxy-space-xs, 0.25rem); color: var(--roxy-fg, #0f172a); font-size: var(--roxy-text-sm, 0.875rem); } .interp-keywords { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.5rem; } .interp-keywords .kw { padding: 1px 8px; border-radius: 9999px; background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 14%, transparent); color: var(--roxy-accent-fg, #b45309); font-size: var(--roxy-text-xs, 0.75rem); } `, ]; @property({ attribute: false }) data: NatalChartResponse | null = null; @property({ type: String, attribute: 'house-system', reflect: true }) houseSystem: 'placidus' | 'whole-sign' | 'equal' | 'koch' = 'placidus'; private getPlanets(): PlanetEntry[] { return this.data?.planets ?? []; } private getAscendant(): number { return this.data?.ascendant?.longitude ?? 0; } private getMidheaven(): number | null { const m = this.data?.midheaven?.longitude; return typeof m === 'number' ? m : null; } private toAngle(lon: number): number { return 180 + this.getAscendant() - lon; } render() { if (!this.data) return html`
No chart data
`; const planets = this.getPlanets(); const aspects = this.data.aspects ?? []; return html`

Natal chart

${ this.data.birthDetails ? html`
${[this.data.birthDetails.date, this.data.birthDetails.time] .filter(Boolean) .join(' · ')}
` : nothing }
Natal chart wheel Twelve zodiac sign segments around a circular wheel. Planet glyphs are placed at their ecliptic longitudes. Aspect lines connect related planets. ${this.renderSpokes()} ${this.renderSigns()} ${this.renderHouseNumbers()} ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)} ${this.renderAngles()}
${planets.length} planets ${aspects.length} aspects harmonious challenging
${this.renderDetails()} ${this.renderInterpretations()}
`; } private renderAngles() { const asc = this.getAscendant(); const mc = this.getMidheaven(); const items = [this.renderAngleMark(asc, 'ASC')]; if (mc !== null) items.push(this.renderAngleMark(mc, 'MC')); return items; } private renderAngleMark(longitude: number, label: string) { const angle = this.toAngle(longitude); const tickInner = polarToCartesian(CENTER, CENTER, OUTER_R, angle); const tickOuter = polarToCartesian(CENTER, CENTER, ANGLE_TICK_R, angle); const labelPos = polarToCartesian(CENTER, CENTER, ANGLE_LABEL_R, angle); return svg` ${label} `; } private renderSpokes() { return Array.from({ length: 12 }, (_, i) => { const angle = this.toAngle(i * 30); const start = polarToCartesian(CENTER, CENTER, HOUSE_R, angle); const end = polarToCartesian(CENTER, CENTER, OUTER_R, angle); return svg``; }); } private renderSigns() { return SIGNS_ORDER.map((sign, i) => { const angle = this.toAngle(i * 30 + 15); const pos = polarToCartesian(CENTER, CENTER, SIGN_R, angle); return svg`${SIGN_GLYPH[sign]}`; }); } private renderHouseNumbers() { const ascSignIndex = Math.floor(this.getAscendant() / 30); return Array.from({ length: 12 }, (_, i) => { const angle = this.toAngle(i * 30 + 15); const pos = polarToCartesian(CENTER, CENTER, HOUSE_R - 12, angle); const houseNum = ((i - ascSignIndex + 12) % 12) + 1; return svg`${houseNum}`; }); } private renderPlanets(planets: PlanetEntry[]) { return planets.map((p) => { if (!Number.isFinite(p.longitude)) return nothing; const angle = this.toAngle(p.longitude); const pos = polarToCartesian(CENTER, CENTER, PLANET_R, angle); const glyph = PLANET_GLYPH[capitalize(p.name)] ?? p.name.slice(0, 2); const retro = p.isRetrograde ? ' R' : ''; const display = retro ? `${glyph}ᴿ` : glyph; return svg`${p.name}${retro}${display}`; }); } private renderDetails() { const summary = this.data?.summary; const ai = this.data?.aspectsInterpretation; if (!summary && !ai) return nothing; const retrogrades = summary?.retrogradePlanets ?? []; const elementDist = summary?.elementDistribution ?? {}; const modalityDist = summary?.modalityDistribution ?? {}; const elementMax = Math.max(1, ...Object.values(elementDist)); const modalityMax = Math.max(1, ...Object.values(modalityDist)); return html`
${ summary?.dominantElement || summary?.dominantModality ? html`
${summary.dominantElement ? html`Dominant element: ${summary.dominantElement}` : nothing} ${summary.dominantModality ? html`Dominant modality: ${summary.dominantModality}` : nothing}
` : nothing } ${ ai ? html`
Harmonious ${ai.harmonious} Challenging ${ai.challenging} Neutral ${ai.neutral}
` : nothing } ${ retrogrades.length > 0 ? html`
${retrogrades.map((p) => { const glyph = PLANET_GLYPH[p] ?? p.slice(0, 2); return html`${glyph} ${p} R`; })}
` : nothing } ${ai?.summary ? html`

${ai.summary}

` : nothing} ${ Object.keys(elementDist).length > 0 || Object.keys(modalityDist).length > 0 ? html`
${ Object.keys(elementDist).length > 0 ? html`

Elements

${Object.entries(elementDist).map( ([label, count]) => html`
${label}
${count}
`, )}
` : nothing } ${ Object.keys(modalityDist).length > 0 ? html`

Modalities

${Object.entries(modalityDist).map( ([label, count]) => html`
${label}
${count}
`, )}
` : nothing }
` : nothing }
`; } private renderInterpretations() { const planets = this.getPlanets().filter((p) => p.interpretation); if (planets.length === 0) return nothing; return html`

Planet readings

${planets.map((p, idx) => { const interp = p.interpretation!; const glyph = PLANET_GLYPH[capitalize(p.name)] ?? ''; const deg = formatNumber(p.degree ?? 0, 1); return html`
${glyph} ${p.name} ${p.sign ?? ''} ${deg}
${interp.summary ? html`

${interp.summary}

` : nothing} ${interp.detailed ? html`

${interp.detailed}

` : nothing} ${ interp.keywords?.length ? html`
${interp.keywords.map((k) => html`${k}`)}
` : nothing }
`; })}
`; } private renderAspects(planets: PlanetEntry[], aspects: AspectEntry[]) { const planetMap = new Map(); for (const p of planets) { if (typeof p.longitude !== 'number') continue; const name = capitalize(p.name); if (name) planetMap.set(name, p.longitude); } return aspects.map((a) => { const l1 = planetMap.get(capitalize(a.planet1)); const l2 = planetMap.get(capitalize(a.planet2)); if (l1 === undefined || l2 === undefined) return nothing; const p1 = polarToCartesian( CENTER, CENTER, PLANET_R - 18, this.toAngle(l1), ); const p2 = polarToCartesian( CENTER, CENTER, PLANET_R - 18, this.toAngle(l2), ); const aspectName = normalizeAspect(a); const aspectClass = ASPECT_CLASS[aspectName] ?? 'aspect-other'; const orbLabel = formatNumber(a.orb, 1); return svg`${a.planet1} ${aspectName || ''} ${a.planet2}${orbLabel ? ` (orb ${orbLabel}°)` : ''}`; }); } } declare global { interface HTMLElementTagNameMap { 'roxy-natal-chart': RoxyNatalChart; } }