import { css, html, nothing, svg } from 'lit'; import { synastryChartEndpoints } from '../generated/endpoint-defaults.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 { ChartViewport, chartViewportStyles } from '../utils/chart-viewport.js'; import { customElement } from '../utils/define.js'; import { arcSeparation, fanOut, formatDegreeInSign, formatWheelDegree, GLYPH_LABEL_GAP, longitudeToSignPosition, type MarkSize, polarToCartesian, rayClearance, } 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'; import { MeasuredType } from '../utils/type-metrics.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; /** The viewBox leaves room past the rim for the two ascendant labels, which sit outside the zodiac like the angles on the natal wheel. */ const SIZE = 680; const CENTER = SIZE / 2; const OUTER_R = 204; const SIGN_R = 186; const P1_R = 160; const P2_R = 258; /** Ascendant tick and label: the tick stops short of the text, and the label sits inside the viewBox edge by more than half its own width. */ const ASC_TICK_R = 212; const ASC_LABEL_R = 224; const HUB_R = 86; /** * The in-wheel type sizes at a wide host, in user units, as the stylesheet * declares them: what the fan spaces the marks by until the rendered text has * been measured, and what a unit test draws on. The phone sizes live in the * stylesheet alone and are read back off the rendered text, never assumed. */ export const SYNASTRY_TYPE_SIZES = { glyph: 18, degree: 10 } as const; /** * Widths a fanned cluster has to clear, as type metrics in em measured on the * rendered text and multiplied by the font size in play. A glyph here carries * its person tag, which is what makes it wider than the same glyph elsewhere; * a retrograde mark widens a label by an em and a quarter, which is why the * separation is settled per pair. */ const GLYPH_EM = 1.3; const WHOLE_DEG_EM = 1.85; const RETRO_MARK_EM = 1.25; /** The ink heights of a glyph and of a degree label: the person tag rides inside the glyph's height. */ const GLYPH_INK_EM = 0.95; const DEG_LABEL_INK_EM = 0.9; /** Leader line: a tick at the body's true longitude and the foot of the line beside the displaced glyph, both outward from the ring, into the gap above it. */ const LEADER_TICK = 8; const LEADER_FOOT = 4; /** * Dual-wheel synastry chart with inter-aspects table. Pass `data` from * /astrology/synastry. */ @customElement('roxy-synastry-chart') export class RoxySynastryChart extends RoxyDataElement { static endpoints = synastryChartEndpoints; private readonly viewport = new ChartViewport(this); /** The in-wheel type sizes the stylesheet actually applied, read back after each render, because the fan spaces the marks by the width of their text and that width follows the container query. */ private readonly type = new MeasuredType(this, { glyph: ['.p1', SYNASTRY_TYPE_SIZES.glyph], degree: ['.planet-deg', SYNASTRY_TYPE_SIZES.degree], }); static styles = [ baseStyles, chartViewportStyles, aspectLineStyles, disclosureStyles, interpAccordionStyles, css` td .interp-body { white-space: normal; min-width: 16rem; max-width: 38rem; } .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, 800px); 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-ink, #b45309); font-weight: 600; font-size: 18px; } .p2 { fill: var(--roxy-info, #0284c7); font-weight: 600; font-size: 18px; } .person-tag { font-size: 10px; font-weight: 700; opacity: 0.85; } .planet-deg { fill: var(--roxy-muted, #71717a); font-size: 10px; font-family: var(--roxy-font-sans); } /* The size the table sets on .retro never reaches the mark inside a wheel label. */ .planet-deg .retro { fill: var(--roxy-danger, #dc2626); font-size: inherit; } .leader { stroke-width: 0.5; opacity: 0.7; } .leader.p1 { stroke: var(--roxy-accent, #f59e0b); } .leader.p2 { stroke: var(--roxy-info, #0284c7); } /* Below 480px the card shrinks to roughly 320px on a phone, so lift the * in-SVG type proportionally or the 7px degree band lands under 6px. */ @container (max-width: 480px) { .sign, .p1, .p2 { font-size: 28px; } .person-tag, .planet-deg { font-size: 15px; } } .asc-tick { stroke: var(--roxy-accent-ink, #b45309); stroke-width: 1.5; } .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 * who reads 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(--_success-fg); } .pill--danger { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 12%, transparent); color: var(--_danger-fg); } .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(--_success-fg); } .asp-name.challenging { color: var(--_danger-fg); } .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); } .overlay-dir { margin-top: var(--roxy-space-md, 1rem); } .overlay-dir:first-of-type { margin-top: 0; } .overlay-dir h4 { margin: 0; display: flex; align-items: baseline; gap: 0.4rem; font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); } .overlay-house { font-variant-numeric: tabular-nums; font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-accent-ink, #b45309); } .own-house { font-variant-numeric: tabular-nums; color: var(--roxy-muted, #71717a); } .retro { margin-inline-start: 0.3em; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); } 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.viewport.renderChartViewport( html` ${this.t('Synastry dual wheel')} ${this.renderSpokes()} ${this.renderSigns()} ${this.renderInterAspectLines(p1Planets, p2Planets, interAspects)} ${this.renderRing(p1Planets, P1_R, (angle, glyph, label) => P1_R - rayClearance(angle, glyph, label, GLYPH_LABEL_GAP), 'p1', 1)} ${this.renderRing(p2Planets, P2_R, (angle, glyph, label) => P2_R + rayClearance(angle, glyph, label, GLYPH_LABEL_GAP), 'p2', 2)} ${this.renderAscendants(d)} `, this.t('Synastry dual wheel'), this.translator, )}
${this.t('Person 1')} ${this.t('Person 2')} ${this.t('Harmonious aspects')} ${this.t('Challenging aspects')} ${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.renderOverlay(person1, person2)} ${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')} ${formatDegreeInSign(asc.degree)}` : '', ], ]; 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}
`; } /** * The house overlay, both directions: which of the other person's houses each planet falls into. * * @remarks * **This is the half of synastry the inter-aspects do not cover.** An aspect says two bodies are * in relationship; an overlay says which area of the other person's life a body lands in, which * is the reading a practitioner gives as "your Venus falls in their seventh". The response * carries it per planet for both people, so both directions render: they are different readings, * not one fact seen twice, and showing one would be half the answer. * * Each row also keeps the planet's OWN house, because the two are easy to confuse and the * response is explicit that they are different numbers. Printing the overlay alone would leave a * reader unable to tell which chart a house number belongs to. * * Data, not reading, so `hide-readings` leaves all of it standing. * * **The wheel does not draw this and cannot yet**: a synastry response carries each person's * ascendant but no house cusps, so the ring the overlay is counted against is not in the * payload, and deriving it would mean assuming a house system the API did not use. */ private renderOverlay( p1: SynastryPerson | undefined, p2: SynastryPerson | undefined, ) { if (!p1?.planets?.length || !p2?.planets?.length) return nothing; const name = (p: SynastryPerson | undefined, n: 1 | 2) => p?.name || this.t('Person {{n}}', { n }); const n1 = name(p1, 1); const n2 = name(p2, 2); return html`

${this.t('Houses')}

${this.renderOverlayTable(p1.planets, n1, n2, 1)} ${this.renderOverlayTable(p2.planets, n2, n1, 2)}
`; } /** One direction of the overlay: every planet of `from`, read against the houses of `to`. */ private renderOverlayTable( planets: readonly PlanetEntry[], from: string, to: string, index: 1 | 2, ) { const rows = planets.filter((p) => typeof p.houseInOtherChart === 'number'); if (rows.length === 0) return nothing; const id = `overlay-${index}`; return html`

${this.t('{{first}} in the houses of {{second}}', { first: from, second: to, })}

${rows.map( (p) => html``, )}
${this.t('Planet')} ${this.t('Sign')} ${this.t('Degree')} ${this.t('House')} ${this.t('Own house')}
${display(p, 'name')}${ p.isRetrograde ? html`℞` : nothing } ${display(p, 'sign')} ${formatDegreeInSign(p.degree)} ${p.houseInOtherChart} ${p.house ?? ''}
`; } /** 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: {{count}}', { count: s.harmonious })} ${this.t('Challenging: {{count}}', { count: s.challenging })} ${this.t('Neutral: {{count}}', { count: 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 && !meaning?.description?.long && !meaning?.keywords?.length) ) { return html`
${lead}${aside}
`; } return html`
${lead}${chevron()}${aside} ${this.renderAspectMeaning(meaning)}
`; } private renderAspectMeaning(meaning: NonNullable) { return html`
${meaning.relationshipContext ? html`

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

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

${meaning.description.short}

` : nothing} ${meaning.description?.long && meaning.description.long !== meaning.description.short ? html`

${meaning.description.long}

` : 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, HUB_R, 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)}`; }); } /** * One person's ring, fanned apart only as far as legibility demands. * * @remarks * A stellium is ordinary in a real chart and drawing every glyph at its * exact angle prints the cluster as one mark. {@link fanOut} pushes a * crowded member forward and a thin leader runs from the glyph outward to a * tick at its TRUE longitude, into the gap above the ring, so the drawing * never claims a position the response did not give. The separation is * settled per pair from the measured width of each mark at its own radius, * and `labelRadius` is where a degree label sits along its glyph's ray. */ private renderRing( planets: PlanetEntry[], radius: number, labelRadius: (angle: number, glyph: MarkSize, label: MarkSize) => number, cls: string, personIndex: 1 | 2, ) { const glyphBox = { width: GLYPH_EM * this.type.size.glyph, height: GLYPH_INK_EM * this.type.size.glyph, }; const labelBox = (p: PlanetEntry) => ({ width: (WHOLE_DEG_EM + (p.isRetrograde === true ? RETRO_MARK_EM : 0)) * this.type.size.degree, height: DEG_LABEL_INK_EM * this.type.size.degree, }); const glyphSeparation = arcSeparation(glyphBox.width, radius); // Two centred labels clear each other at half the sum of their widths, on the band at the top of the ring. const separation = (a: PlanetEntry, b: PlanetEntry) => Math.max( glyphSeparation, arcSeparation( (labelBox(a).width + labelBox(b).width) / 2, labelRadius(90, glyphBox, labelBox(a)), ), ); return fanOut(planets, (p) => p.longitude, separation).map( ({ item: p, longitude, displayLongitude }) => { const angle = this.toAngle(displayLongitude); const pos = polarToCartesian(CENTER, CENTER, radius, angle); const degPos = polarToCartesian( CENTER, CENTER, labelRadius(angle, glyphBox, labelBox(p)), angle, ); const glyph = planetGlyph(p.name) ?? display(p, 'name'); const retro = p.isRetrograde === true; // 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')}` : ''} - ${formatWheelDegree(longitude)} ${display(p, 'sign')}${house}`; const displaced = Math.abs(displayLongitude - longitude) > 0.5; const tick = polarToCartesian( CENTER, CENTER, radius + (personIndex === 1 ? LEADER_TICK : -LEADER_TICK), this.toAngle(longitude), ); const foot = polarToCartesian( CENTER, CENTER, radius + (personIndex === 1 ? LEADER_FOOT : -LEADER_FOOT), angle, ); return svg` ${ displaced ? svg`` : nothing } ${tooltip}${glyph}${personIndex} ${longitudeToSignPosition(longitude).degree}°${retro ? svg` ℞` : nothing} `; }, ); } /** * Ascendant markers for both people. Drawn as short ticks outside the rim * with the label beyond them, the way the natal wheel marks its angles, so * the two rising signs are immediately scannable without depending on * tooltips. */ private renderAscendants(data: CalculateSynastryResponse) { const marks = ([data.person1, data.person2] as const).flatMap( (person, i) => { const asc = person?.ascendant; if (!asc) return []; const sign = SIGNS_ORDER.findIndex( (s) => s.toLowerCase() === asc.sign.toLowerCase(), ); return sign < 0 ? [] : [{ longitude: sign * 30 + asc.degree, index: i + 1 }]; }, ); return fanOut( marks, (mark) => mark.longitude, arcSeparation(34, ASC_LABEL_R), ).map(({ item, longitude, displayLongitude }) => { const angle = this.toAngle(longitude); const labelAngle = this.toAngle(displayLongitude); const rim = polarToCartesian(CENTER, CENTER, OUTER_R, angle); const tick = polarToCartesian(CENTER, CENTER, ASC_TICK_R, angle); const label = polarToCartesian(CENTER, CENTER, ASC_LABEL_R, labelAngle); const foot = polarToCartesian( CENTER, CENTER, ASC_LABEL_R - 7, labelAngle, ); return svg` ${Math.abs(displayLongitude - longitude) > 0.5 ? svg`` : nothing} ${this.t('ASC{{n}}', { n: item.index })} `; }); } 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, HUB_R - 2, this.toAngle(l1)); const inn = polarToCartesian(CENTER, CENTER, HUB_R - 2, 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` ${!this.hideReadings ? html`` : nothing} ${aspects.map( (a) => html` ${!this.hideReadings ? html`` : nothing} `, )}
${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')}${this.t('Reading')}
${display(a, 'planet1')} ${display(a, 'planet2')} ${display(a, 'type', formatAspectName(a))} ${formatNumber(this.effectiveLang(), a.orb, 1)} ${formatNumber(this.effectiveLang(), a.strength, 0)}${a.meaning ? html`
${chevron()} ${this.t('Reading')}${this.renderAspectMeaning(a.meaning)}
` : nothing}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-synastry-chart': RoxySynastryChart; } }