import { css, html, nothing, svg } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { ChromeString } from '../i18n/chrome-strings.js'; import { planetGlyph, SIGNS_ORDER, signGlyph } from '../tokens/index.js'; import type { CalculateSynastryResponse } from '../types/index.js'; import { aspectLineStyle, aspectLineStyles } from '../utils/aspect-line.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { longitudeToSignPosition, polarToCartesian } from '../utils/degree.js'; import { chevron, disclosureStyles } from '../utils/disclosure.js'; import { ASPECT_CLASS, formatAspectName, formatNumber, normalizeAspect, } from '../utils/format.js'; import { interpAccordionStyles, renderKeywordChips, } from '../utils/interp-accordion.js'; import { display } from '../utils/localized.js'; import { capitalize } from '../utils/string.js'; /** * A planet as the synastry response now returns it. * * @remarks * Was `NatalChartResponse['planets'][number]`, because `/astrology/synastry` did not return positions and a caller had to merge two natal responses in by hand. The endpoint returns them, so the wheel is drawable from ONE call. The synastry shape is the plotting subset (no `interpretation`, `speed` or `latitude`); the wheel only ever read `name`, `longitude` and `isRetrograde`, so nothing is lost. */ type PlanetEntry = CalculateSynastryResponse['person1']['planets'][number]; type InterAspect = CalculateSynastryResponse['interAspects'][number]; type SynastrySummary = CalculateSynastryResponse['summary']; type SynastryPerson = CalculateSynastryResponse['person1']; /** How many inter-aspects get a full reading before the rest fall back to the catalog table. A synastry can return 90+ contacts; a practitioner works the tightest ones. */ const READING_COUNT = 12; 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 RoxyDataElement { static styles = [ baseStyles, aspectLineStyles, disclosureStyles, interpAccordionStyles, 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); padding: var(--roxy-space-lg, 1.5rem); box-shadow: var(--roxy-shadow-sm); display: grid; /* minmax(0, 1fr), not the implicit auto column. An auto grid column takes * its MINIMUM from min-content, so a nowrap table wider than the card blows * the column out and drags every sibling with it, clipped on the right. This * is what lets the scroll container inside actually scroll. */ grid-template-columns: minmax(0, 1fr); 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-ink, #b45309); font-size: var(--roxy-text-xl, 1.5rem); } svg { display: block; width: 100%; max-width: var(--roxy-chart-max-width, 560px); aspect-ratio: 1 / 1; height: auto; 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; } .person-tag { font-size: 7px; font-weight: 700; opacity: 0.85; } .planet-deg { fill: var(--roxy-muted, #71717a); font-size: 7px; font-family: var(--roxy-font-sans); } .planet-deg .retro { fill: var(--roxy-danger, #dc2626); } .asc-tick { stroke: var(--roxy-accent-ink, #b45309); stroke-width: 1; opacity: 0.75; } .asc-label { fill: var(--roxy-accent-ink, #b45309); font-size: 9px; font-weight: 700; font-family: var(--roxy-font-sans); letter-spacing: 0.04em; } .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); } /* The wheel divides by SIGN. Twelve spokes read as house cusps to anyone * used to a house wheel, so the legend states what they are rather than * leaving the drawing to imply houses this response does not carry. */ .legend-row .caveat { font-style: italic; } .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); } .people { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: var(--roxy-space-sm, 0.5rem); } .person { 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); display: grid; gap: 0.3rem; } .person-name { display: flex; align-items: center; gap: 0.4rem; font-weight: var(--roxy-weight-bold, 600); font-size: var(--roxy-text-sm, 0.875rem); } .person-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .person-dot.p1 { background: var(--roxy-accent, #f59e0b); } .person-dot.p2 { background: var(--roxy-info, #0284c7); } .big-three { display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); } .big-three span { display: inline-flex; align-items: baseline; gap: 0.25rem; } .big-three .lbl { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } .summary-pills { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem); } .pill { padding: 2px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 60%, transparent); color: var(--roxy-fg, #0a0a0a); } .pill--success { background: color-mix(in srgb, var(--roxy-success, #16a34a) 12%, transparent); color: var(--roxy-success-fg, #166534); } .pill--danger { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 12%, transparent); color: var(--roxy-danger-fg, #991b1b); } .glyph { font-size: 1.1em; line-height: 1; } /* An aspect with no meaning block has nothing to disclose, so it renders * as a flat row rather than an empty accordion. Same header, no chevron. */ .static-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--roxy-space-sm, 0.5rem); font-size: var(--roxy-text-sm, 0.875rem); font-weight: 500; } .asp-name { font-weight: var(--roxy-weight-bold, 600); text-transform: capitalize; } .asp-name.harmonious { color: var(--roxy-success-fg, #166534); } .asp-name.challenging { color: var(--roxy-danger-fg, #991b1b); } .context { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--roxy-weight-bold, 600); } .catalog > summary { cursor: pointer; font-weight: var(--roxy-weight-bold, 600); font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); display: flex; align-items: center; gap: 0.5rem; } .scroll { overflow-x: auto; min-width: 0; margin-top: var(--roxy-space-sm, 0.5rem); } 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; } `, ]; protected renderData(d: CalculateSynastryResponse) { const { person1, person2, compatibilityScore, analysis } = d; const interAspects = d.interAspects ?? []; const p1Planets = person1?.planets ?? []; const p2Planets = person2?.planets ?? []; const score = typeof compatibilityScore === 'number' ? Math.round(compatibilityScore) : undefined; const summaryText = analysis?.overall; const strengths = analysis?.strengths ?? []; const challenges = analysis?.challenges ?? []; // /astrology/synastry returns chart highlights (Sun, Moon, Ascendant) but // not full planet positions, so the dual wheel cannot be drawn from a bare // synastry response. Say so instead of rendering a blank wheel; every other // block still renders, so a caller who passes the bare response gets the // full reading minus the drawing. const hasPlanets = p1Planets.length > 0 && p2Planets.length > 0; // The tightest contacts carry the reading. Sort a copy: `data` is the // caller's object. const ranked = [...interAspects].sort( (a, b) => (b.strength ?? 0) - (a.strength ?? 0), ); const lead = ranked.slice(0, READING_COUNT); return html`

${this.t('Synastry')}

${ typeof score === 'number' ? html`${score} / 100` : nothing }
${this.renderPeople(person1, person2)} ${ hasPlanets ? html` ${this.t('Synastry dual wheel')} ${this.renderSpokes()} ${this.renderSigns()} ${this.renderInterAspectLines(p1Planets, p2Planets, interAspects)} ${this.renderRing(p1Planets, P1_R, 'p1', 1)} ${this.renderRing(p2Planets, P2_R, 'p2', 2)} ${this.renderAscendants(d)}
${this.t('Person 1')} ${this.t('Person 2')} ${this.t('Harmonious')} ${this.t('Challenging')} ${this.t('Sign sectors, not houses')}
` : html`
${this.t('Synastry response missing planet positions.')} ${this.t( 'A current {{endpoint}} response carries {{first}} and {{second}}, and the inter-aspect readings below still work without them.', { endpoint: '/astrology/synastry', first: 'person1.planets', second: 'person2.planets', }, )}
` } ${this.renderSummaryPills(d.summary)} ${ // The contact counts above are the data; this paragraph is the read. summaryText && !this.hideReadings ? html`

${summaryText}

` : nothing } ${ lead.length > 0 ? html`

${this.t('Inter-aspects')}

${lead.map((a, i) => this.renderAspectCard(a, i))}
` : nothing } ${ranked.length > lead.length ? this.renderCatalog(ranked) : nothing} ${ // Each entry is a sentence about the pair, so the two lists are prose // laid out as bullets rather than data. !this.hideReadings && (strengths.length > 0 || challenges.length > 0) ? html`
${ strengths.length ? html`

${this.t('Strengths')}

    ${strengths.map((s) => html`
  • ${s}
  • `)}
` : nothing } ${ challenges.length ? html`

${this.t('Challenges')}

    ${challenges.map((s) => html`
  • ${s}
  • `)}
` : nothing }
` : nothing }
`; } /** Chart highlights for both people: the Sun, Moon, and Ascendant a reader checks before any aspect. Colour-dotted to match each person ring on the wheel. */ private renderPeople( p1: SynastryPerson | undefined, p2: SynastryPerson | undefined, ) { const card = (p: SynastryPerson | undefined, index: 1 | 2) => { if (!p) return nothing; // Three columns, not two: the sign the GLYPH is keyed on stays English // whatever the page language, and only the third is read. const asc = p.ascendant; const big: Array<[ChromeString, string, string]> = [ ['Sun', p.sunSign ?? '', display(p, 'sunSign')], ['Moon', p.moonSign ?? '', display(p, 'moonSign')], [ 'ASC', asc?.sign ?? '', asc ? `${display(asc, 'sign')} ${formatNumber(this.effectiveLang(), asc.degree, 0)}°` : '', ], ]; const shown = big.filter(([, , text]) => text); if (shown.length === 0) return nothing; return html`
${p.name || this.t('Person {{n}}', { n: index })}
${shown.map( ([label, sign, text]) => html` ${this.t(label)} ${text} `, )}
`; }; const cards = [card(p1, 1), card(p2, 2)].filter((c) => c !== nothing); if (cards.length === 0) return nothing; return html`
${cards}
`; } /** Contact balance for the pair. `byType` is a map, so its pairs are rendered, never the object. */ private renderSummaryPills(s: SynastrySummary | undefined) { if (!s || typeof s !== 'object') return nothing; const byType = Object.entries(s.byType ?? {}).sort((a, b) => b[1] - a[1]); if (typeof s.total !== 'number' && byType.length === 0) return nothing; return html`
${typeof s.total === 'number' ? html`${this.t('Total')}: ${s.total}` : nothing} ${this.t('Harmonious')}: ${s.harmonious} ${this.t('Challenging')}: ${s.challenging} ${this.t('Neutral')}: ${s.neutral} ${byType.map( ([type, count]) => html`${formatAspectName({ type })}: ${count}`, )}
`; } /** * One inter-chart contact as a reading. The header is the scannable line (both bodies, the aspect coloured by nature, orb and strength); the body leads with `meaning.relationshipContext`, which is the whole point of a synastry aspect: not what a trine means, but what THIS trine does to THESE two people. */ private renderAspectCard(a: InterAspect, index: number) { const g1 = planetGlyph(a.planet1) ?? ''; const g2 = planetGlyph(a.planet2) ?? ''; const nature = (a.interpretation ?? 'neutral').toLowerCase(); const meaning = a.meaning; const lead = html` ${display(a, 'planet1')} ${display(a, 'type', formatAspectName(a))} ${display(a, 'planet2')} `; const aside = html` ${this.t('orb {{orb}}° · str {{strength}}', { orb: formatNumber(this.effectiveLang(), a.orb, 2), strength: formatNumber(this.effectiveLang(), a.strength, 0) })} `; // The header is the contact itself (both bodies, the aspect, orb and // strength) and is never a reading, so a card with nothing to disclose // already renders flat. Hiding the readings reuses that shape rather than // dropping the contact. if ( this.hideReadings || (!meaning?.relationshipContext && !meaning?.description?.short) ) { return html`
${lead}${aside}
`; } return html`
${lead}${chevron()}${aside}
${ meaning.relationshipContext ? html`

${this.t('In this pairing')} ${meaning.relationshipContext}

` : nothing } ${meaning.description?.short ? html`

${meaning.description.short}

` : nothing} ${renderKeywordChips(meaning.keywords)}
`; } /** Every remaining contact, so nothing the endpoint returned is dropped. */ private renderCatalog(ranked: InterAspect[]) { return html`
${chevron()} ${this.t('All {{count}} inter-aspects', { count: ranked.length })}
${this.renderAspects(ranked)}
`; } private toAngle(longitude: number): number { return 180 - longitude; } private renderSpokes() { return Array.from({ length: 12 }, (_, i) => { const angle = this.toAngle(i * 30); const start = polarToCartesian(CENTER, CENTER, P2_R - 14, angle); const end = polarToCartesian(CENTER, CENTER, OUTER_R, angle); return svg``; }); } private renderSigns() { return SIGNS_ORDER.map((s, i) => { const angle = this.toAngle(i * 30 + 15); const pos = polarToCartesian(CENTER, CENTER, SIGN_R, angle); return svg`${signGlyph(s)}`; }); } private renderRing( planets: PlanetEntry[], radius: number, cls: string, personIndex: 1 | 2, ) { return planets.map((p) => { if (!Number.isFinite(p.longitude)) return nothing; const angle = this.toAngle(p.longitude); const pos = polarToCartesian(CENTER, CENTER, radius, angle); // Degree label sits one tier inward from the glyph so the two // concentric rings never blur their numbers into the aspect lines. const degOffset = personIndex === 1 ? -12 : -10; const degPos = polarToCartesian( CENTER, CENTER, radius + degOffset, angle, ); const glyph = planetGlyph(p.name) ?? display(p, 'name'); const sp = longitudeToSignPosition(p.longitude); const retro = p.isRetrograde === true; const degLabel = `${sp.degree}°${String(sp.minute).padStart(2, '0')}'`; // The house each planet holds in its own chart. const house = typeof p.house === 'number' ? ` · ${this.t('House {{n}}', { n: p.house })}` : ''; // The sign comes from the response rather than from the longitude, so the // tooltip cannot disagree with the sign the API assigned. const tooltip = `${display(p, 'name')}${retro ? ` ${this.t('retrograde')}` : ''} - ${degLabel} ${display(p, 'sign')}${house}`; return svg` ${tooltip}${glyph}${personIndex} ${sp.degree}°${retro ? svg`` : nothing} `; }); } /** * Ascendant markers for both people. Drawn as small spokes at the inner * rim with the label outside, so the two rising signs are immediately * scannable on the wheel without depending on tooltips. */ private renderAscendants(data: CalculateSynastryResponse) { const items: ReturnType[] = []; const make = ( asc: { sign: string; degree: number } | undefined, personIndex: 1 | 2, ) => { if (!asc) return; const signIdx = SIGNS_ORDER.findIndex( (s) => s.toLowerCase() === asc.sign.toLowerCase(), ); if (signIdx === -1) return; const longitude = signIdx * 30 + asc.degree; const angle = this.toAngle(longitude); const innerR = personIndex === 1 ? P1_R + 14 : P2_R + 14; const tickPos = polarToCartesian(CENTER, CENTER, innerR, angle); const labelPos = polarToCartesian(CENTER, CENTER, OUTER_R + 14, angle); items.push(svg` ${this.t('ASC{{n}}', { n: personIndex })} `); }; make(data.person1?.ascendant, 1); make(data.person2?.ascendant, 2); return items; } private renderInterAspectLines( p1: PlanetEntry[], p2: PlanetEntry[], aspects: InterAspect[], ) { const longitudeOf = ( list: PlanetEntry[], name: string, ): number | undefined => { const target = capitalize(name); for (const p of list) { if (capitalize(p.name) !== target) continue; if (typeof p.longitude === 'number') return p.longitude; } return undefined; }; return aspects.map((a) => { const l1 = longitudeOf(p1, a.planet1); const l2 = longitudeOf(p2, a.planet2); if (l1 === undefined || l2 === undefined) return nothing; const out = polarToCartesian(CENTER, CENTER, P1_R - 12, this.toAngle(l1)); const inn = polarToCartesian(CENTER, CENTER, P2_R + 8, this.toAngle(l2)); const aspectName = normalizeAspect(a); const cls = ASPECT_CLASS[aspectName] ?? 'aspect-other'; const orbLabel = formatNumber(this.effectiveLang(), a.orb, 1); return svg`${display(a, 'planet1')} ${display(a, 'type', formatAspectName(a))} ${display(a, 'planet2')}${orbLabel ? ` (${this.t('Orb')} ${orbLabel}°)` : ''}`; }); } private renderAspects(aspects: InterAspect[]) { return html` ${aspects.map( (a) => html``, )}
${this.t( 'Inter-chart aspects: the planet from chart 1, the planet from chart 2, the aspect between them, the orb in degrees and the strength.', )}
${this.t('Planet 1')} ${this.t('Planet 2')} ${this.t('Aspect')} ${this.t('Orb')} ${this.t('Strength')}
${display(a, 'planet1')} ${display(a, 'planet2')} ${display(a, 'type', formatAspectName(a))} ${formatNumber(this.effectiveLang(), a.orb, 1)} ${formatNumber(this.effectiveLang(), a.strength, 0)}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-synastry-chart': RoxySynastryChart; } }