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 { CalculateSynastryResponse, 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 InterAspect = CalculateSynastryResponse['interAspects'][number]; // Drawing the dual wheel requires per-person planet longitudes alongside // the synastry response. Callers can merge planet arrays from // /astrology/natal-chart into `person1.planets` and `person2.planets` // before passing the payload in; without them, the component falls back // to the inter-aspects table and a status note instead of an empty wheel. type SynastryWithPlanets = CalculateSynastryResponse & { person1?: { planets?: PlanetEntry[] }; person2?: { planets?: PlanetEntry[] }; }; const SIZE = 360; const CENTER = SIZE / 2; const OUTER_R = 170; const SIGN_R = 154; const P1_R = 124; const P2_R = 96; /** * Dual-wheel synastry chart with inter-aspects table. Pass `data` from * /astrology/synastry. */ @customElement('roxy-synastry-chart') export class RoxySynastryChart extends LitElement { static styles = [ baseStyles, css` .wrap { display: grid; gap: var(--roxy-space-md, 1rem); } .head { display: flex; justify-content: space-between; align-items: center; gap: var(--roxy-space-md, 1rem); flex-wrap: wrap; } .title { font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); margin: 0; } .score { font-variant-numeric: tabular-nums; font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-accent-fg, #b45309); font-size: var(--roxy-text-xl, 1.5rem); } svg { display: block; width: 100%; max-width: 400px; margin: 0 auto; } .wheel-line { fill: none; stroke: var(--roxy-border, #e4e4e7); } .sign { fill: var(--roxy-secondary, #475569); font-size: 14px; } .p1 { fill: var(--roxy-accent, #f59e0b); font-weight: 600; font-size: 13px; } .p2 { fill: var(--roxy-info, #0284c7); font-weight: 600; font-size: 13px; } .aspect { stroke-width: 0.8; fill: none; opacity: 0.5; } .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.35; } .legend-row { display: flex; flex-wrap: wrap; gap: var(--roxy-space-md, 1rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); margin-top: calc(var(--roxy-space-xs, 0.25rem) * -1); } .legend-row .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .summary { margin: 0; color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-base, 1rem); } table { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); } th, td { padding: var(--roxy-space-sm, 0.5rem); border-bottom: 1px solid var(--roxy-border, #e4e4e7); text-align: left; } th { color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; font-size: var(--roxy-text-xs, 0.75rem); letter-spacing: 0.06em; } td.orb { font-variant-numeric: tabular-nums; color: var(--roxy-muted, #71717a); } .lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: var(--roxy-space-md, 1rem); } .lists h3 { margin: 0 0 var(--roxy-space-xs, 0.25rem) 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.06em; } .lists ul { margin: 0; padding-left: var(--roxy-space-md, 1rem); font-size: var(--roxy-text-sm, 0.875rem); } .missing-planets { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 8%, transparent); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); padding: var(--roxy-space-md, 1rem); color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); line-height: 1.5; } .missing-planets code { font-family: var(--roxy-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.95em; background: color-mix(in srgb, var(--roxy-fg, #0a0a0a) 6%, transparent); padding: 0 4px; border-radius: 4px; } `, ]; @property({ attribute: false }) data: SynastryWithPlanets | null = null; render() { if (!this.data) return html`
data with person1.planets and
person2.planets arrays from the natal-chart endpoint, or
use the <roxy-data> fallback.
${summaryText}
` : nothing} ${interAspects.length > 0 ? this.renderAspects(interAspects) : nothing} ${ strengths.length > 0 || challenges.length > 0 ? html`${summaryText}
` : nothing} ${interAspects.length > 0 ? this.renderAspects(interAspects) : nothing} ${ strengths.length > 0 || challenges.length > 0 ? html`| Planet 1 | Planet 2 | Aspect | Orb | Strength |
|---|---|---|---|---|
| ${a.planet1} | ${a.planet2} | ${normalizeAspect(a) || ''} | ${formatNumber(a.orb, 1)} | ${formatStrength(a.strength)} |