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`
${ai.summary}
` : nothing} ${ Object.keys(elementDist).length > 0 || Object.keys(modalityDist).length > 0 ? html`${interp.summary}
` : nothing} ${interp.detailed ? html`${interp.detailed}
` : nothing} ${ interp.keywords?.length ? html`