${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`
`;
}
/**
* 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,
})}
`;
}
/** 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`
`;
}
/**
* 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`${g1}${display(a, 'planet1')}
${display(a, 'type', formatAspectName(a))}${g2}${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`