import { css, html, nothing, svg } from 'lit'; import { natalChartEndpoints } from '../generated/endpoint-defaults.js'; import './aspects-table.js'; import './western-planets-table.js'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { aspectSymbol, planetGlyph, SIGNS_ORDER, signGlyph, } from '../tokens/index.js'; import type { NatalChartResponse } 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 { arcMidpoint, arcSeparation, fanOut, formatWheelDegree, GLYPH_LABEL_GAP, longitudeToSignPosition, normalizeLongitude, oppositePoint, polarToCartesian, rayClearance, rayReach, staggerRows, } from '../utils/degree.js'; import { disclosureStyles } from '../utils/disclosure.js'; import { ASPECT_CLASS, formatAspectName, formatDateTime, formatNumber, formatPercent, normalizeAspect, } from '../utils/format.js'; import { type InterpSection, interpAccordionStyles, renderKeywordChips, } from '../utils/interp-accordion.js'; import { display, displayList } from '../utils/localized.js'; import { capitalize, lookupKey } from '../utils/string.js'; import { renderTablist, tablistStyles } from '../utils/tablist.js'; import { MeasuredType } from '../utils/type-metrics.js'; type PlanetEntry = NatalChartResponse['planets'][number]; type AspectEntry = NatalChartResponse['aspects'][number]; type PatternEntry = NonNullable[number]; /** * Reading order for detected configurations. A practitioner reads the rare, chart-defining figures first (a Grand Cross reframes the whole chart) and the common ones last, so the response order is re-sorted rather than dumped. Ties break on tightness, the closest to exact first. */ const PATTERN_ORDER: Record = { GRAND_CROSS: 0, GRAND_TRINE: 1, KITE: 2, T_SQUARE: 3, YOD: 4, MYSTIC_RECTANGLE: 5, STELLIUM: 6, }; const SIZE = 540; const CENTER = SIZE / 2; const OUTER_R = 220; const SIGN_R = 202; const HOUSE_R = 174; const PLANET_R = 144; const ANGLE_TICK_R = 232; const ANGLE_LABEL_R = 249; /** * 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 NATAL_TYPE_SIZES = { glyph: 14, degree: 7 } as const; /** * How wide each mark on the wheel is, so {@link arcSeparation} can turn it into * the degrees of arc that mark needs at its own radius. The glyph and the * degree label are type metrics in em, measured on the rendered text and * multiplied by the font size in play: a full `dd°mm'` label is over three * times its font size wide and the retrograde mark adds another em and a * quarter, which is why the label, not the glyph, decides the spacing. The angle * label is fixed in user units: the widest of `ASC`, `DSC`, `MC`, `IC`, `PoF` * and `Vtx` plus the gap that keeps two of them apart. */ const GLYPH_EM = 0.95; const DEG_LABEL_EM = 3.4; const WHOLE_DEG_EM = 1.85; const RETRO_MARK_EM = 1.25; /** The ink of a degree label, digits and marks, is this tall: what two labels must not share, rather than the line box. */ const DEG_LABEL_INK_EM = 0.9; const ANGLE_LABEL_WIDTH = 28; /** A second row this much further in, for a label that would touch its neighbour. */ const DEG_LABEL_STAGGER = 13; /** The hub the aspect lines are drawn inside, under the deepest reach of a label beside a glyph at 3 or 9 o'clock. */ const HUB_R = 84; /** * The chart shape the wheel actually renders. * * @remarks * `RelocationChartResponse` carries the same planets, houses, angles and birth details as `NatalChartResponse` (`RelocationPlanet` is field-for-field identical to the natal planet item), and differs only by having no `aspects`, `aspectsInterpretation`, `patterns`, `partOfFortune` or `summary`. The wheel already renders without them, so those five are simply optional here. * * Typing that contract is what lets {@link RoxyRelocationWheel} compose this component honestly: it hands over a response the type actually describes, rather than a double cast asserting fields the payload may not carry. */ type ChartExtras = | 'aspects' | 'aspectsInterpretation' | 'patterns' | 'partOfFortune' | 'summary'; export type WheelChart = Omit & Partial>; /** * Western natal chart wheel. Renders the 12 zodiac signs, 12 houses, planet * markers, and aspect lines from a /astrology/natal-chart response. * * @remarks * **There is deliberately no `house-system` input, and re-adding one would be a * lie in the types.** The house system is a REQUEST parameter: /astrology/natal-chart * and /astrology/relocation-chart both take it and both echo the system they * actually used as a required `houseSystem` field, which is what the legend * chip reads. This component computes nothing astrological, so an attribute * could never move a cusp; all it could do is print a label contradicting the * cusps drawn beside it. One shipped anyway, declared and typed and wired * through both framework wrappers, and nothing ever read it, so a consumer who * followed our own generated types set it and got silence. Change the system in * the REQUEST (the self-fetch form already offers it, straight off the spec) and * the response relabels itself. * * **The equal-sector fallback is visible, on purpose.** With fewer than twelve * cusps in the response the wheel draws twelve equal 30 degree sectors from the * Ascendant, which is a different chart from a Placidus one; it says so in the * accessible name and in a legend chip rather than numbering the sectors 1 to 12 * and letting a reader assume the response carried them. */ @customElement('roxy-natal-chart') export class RoxyNatalChart extends RoxyDataElement { static endpoints = natalChartEndpoints; private readonly viewport = new ChartViewport(this); static styles = [ baseStyles, chartViewportStyles, aspectLineStyles, tablistStyles, disclosureStyles, interpAccordionStyles, css` roxy-aspects-table::part(card), roxy-western-planets-table::part(card) { border: 0; padding: 0; box-shadow: none; } .wrap { width: 100%; 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); } .title { overflow-wrap: anywhere; 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: var(--roxy-chart-max-width, 680px); aspect-ratio: 1 / 1; 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-ink, #b45309); font-size: 14px; font-weight: 600; font-family: var(--roxy-font-sans); } .planet-deg { fill: var(--roxy-fg, #0a0a0a); font-size: 7px; font-family: var(--roxy-font-sans); } /* Below 480px the chart container shrinks to ~320px on phones. * Bump in-SVG text up proportionally so the 7px degree band * does not collapse below ~6px on screen. */ @container (max-width: 480px) { .sign-glyph, .planet-glyph { font-size: 24px; } .planet-deg { font-size: 13px; } .house-num { font-size: 12px; } } .planet-deg .retro { fill: var(--roxy-danger, #dc2626); } .planet-leader { stroke: var(--roxy-accent, #f59e0b); stroke-width: 0.5; opacity: 0.55; } .house-num { fill: var(--roxy-muted, #71717a); font-size: 9px; font-family: var(--roxy-font-sans); } .cusp-deg { fill: var(--roxy-muted, #71717a); font-size: 6px; font-family: var(--roxy-font-sans); } .tick { stroke: var(--roxy-border, #e4e4e7); } .tick-major { stroke: var(--roxy-secondary, #475569); } .angle-marker { fill: var(--roxy-accent-ink, #b45309); font-size: 13px; font-weight: 700; font-family: var(--roxy-font-sans); letter-spacing: 0.04em; } .angle-tick { stroke: var(--roxy-accent-ink, #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; } /* The equal-sector fallback has to be READ, not blend into the muted * legend beside it, so it takes --roxy-fg and a border. Not a tint with * coloured text: on a tinted chip that measures below the 4.5 floor. */ .legend .caveat { color: var(--roxy-fg, #0a0a0a); font-weight: var(--roxy-weight-bold, 600); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-full, 9999px); padding: 1px 8px; } .grid-scroll { overflow-x: auto; min-width: 0; -webkit-overflow-scrolling: touch; } table.aspect-grid { border-collapse: collapse; font-size: var(--roxy-text-xs, 0.75rem); margin: 0 auto; } table.aspect-grid th, table.aspect-grid td { width: 1.6rem; height: 1.6rem; text-align: center; border: 1px solid var(--roxy-border, #e4e4e7); padding: 0; } table.aspect-grid th { color: var(--roxy-secondary, #475569); font-weight: var(--roxy-weight-bold, 600); } table.aspect-grid td.cell { cursor: default; } table.aspect-grid td.empty { background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 18%, transparent); } table.aspect-grid td .asp { font-size: 0.95em; line-height: 1; } table.aspect-grid td.aspect-trine .asp, table.aspect-grid td.aspect-sextile .asp { color: var(--roxy-success, #16a34a); } table.aspect-grid td.aspect-square .asp, table.aspect-grid td.aspect-opposition .asp { color: var(--roxy-danger, #dc2626); } table.aspect-grid td.aspect-conjunction .asp { color: var(--roxy-accent-ink, #b45309); } table.aspect-grid td.aspect-other .asp { color: var(--roxy-muted, #71717a); } .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); } /* Ink on a status tint is the -fg token, never the base status colour: the * base is tuned to read on the surface, and on a tint of itself it lands * near 3:1. The sibling tables take the same pair. */ .pill--success { background: color-mix(in srgb, var(--roxy-success, #16a34a) 15%, transparent); color: var(--_success-fg); } .pill--danger { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 15%, transparent); color: var(--_danger-fg); } .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; } .em-grid { border-collapse: collapse; font-size: var(--roxy-text-xs, 0.75rem); width: 100%; } .em-grid caption { caption-side: top; text-align: left; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); padding-bottom: var(--roxy-space-xs, 0.25rem); } .em-grid th, .em-grid td { border: 1px solid var(--roxy-border, #e4e4e7); padding: 3px 5px; text-align: center; vertical-align: middle; } .em-grid th { color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.04em; } .em-grid th[scope='row'] { text-align: left; } .em-grid td { color: var(--roxy-accent-ink, #b45309); font-size: 0.95em; line-height: 1.4; min-width: 1.4rem; } .em-grid .em-total { color: var(--roxy-fg, #0a0a0a); font-weight: var(--roxy-weight-bold, 600); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 25%, transparent); } .pattern { 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); display: grid; gap: 0.35rem; } .pattern-head { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; } .pattern-name { font-weight: var(--roxy-weight-bold, 600); } .pattern-tag { padding: 1px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 55%, transparent); color: var(--roxy-fg, #0a0a0a); text-transform: capitalize; } .pattern-tight { margin-left: auto; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); font-variant-numeric: tabular-nums; } .pattern-planets { display: flex; flex-wrap: wrap; gap: 0.25rem; } .planet-chip { display: inline-flex; align-items: baseline; gap: 0.3rem; padding: 1px 8px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 45%, transparent); color: var(--roxy-fg, #0a0a0a); } /* The apex is where the configuration discharges, so it leads the chip * row and carries the accent tint. Text stays --roxy-fg: accent-ink on a * tinted chip fails AA. */ .planet-chip.apex { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 20%, transparent); font-weight: var(--roxy-weight-bold, 600); } .planet-chip .apex-tag { font-size: 0.85em; font-weight: 400; text-transform: uppercase; letter-spacing: 0.04em; } .pattern-interp { color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); line-height: 1.45; margin: 0; } `, ]; /** Heading above the wheel. Defaults to "Natal chart"; reuse (e.g. the relocation wheel) sets its own. */ @property({ type: String }) heading = 'Natal chart'; /** Which view is showing: the wheel or the planet-by-planet aspect grid. */ @state() private view: 'wheel' | 'grid' = 'wheel'; /** 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: ['.planet-glyph', NATAL_TYPE_SIZES.glyph], degree: ['.planet-deg', NATAL_TYPE_SIZES.degree], }); 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; } /** * True when the response carried all twelve house cusps, so every house on the * wheel is a longitude the API sent rather than a 30 degree sector this * component invented. Read by the spokes, the numbers, the cusp degrees, the * accessible name and the legend, so the drawing and the words about it cannot * disagree about which chart is on screen. */ private get hasCusps(): boolean { return (this.data?.houses ?? []).length === 12; } private toAngle(lon: number): number { return 180 + this.getAscendant() - lon; } protected renderData(data: WheelChart) { const planets = this.getPlanets(); const aspects = data.aspects ?? []; const view = this.view; return html`

${this.t(this.heading)}

${ data.birthDetails ? html`
${formatDateTime(this.effectiveLang(), data.birthDetails.date, data.birthDetails.time)}
` : nothing }
${ aspects.length > 0 ? html`${renderTablist({ items: [ { id: 'wheel', label: this.t('Wheel') }, { id: 'grid', label: this.t('Aspect grid') }, ], active: view, onSelect: (v) => { this.view = v; }, label: this.t('Natal chart views'), idPrefix: 'natal', controls: true, })}
${view === 'wheel' ? this.renderWheel(planets, aspects) : this.renderAspectGrid(planets, aspects)}
` : this.renderWheel(planets, aspects) }
${this.t('Positions')}: ${formatNumber(this.effectiveLang(), planets.length, 0)} ${aspects.length > 0 ? html`${this.t('{{count}} aspects', { count: aspects.length })}` : nothing} ${ // The system names the CUSPS, so it is only true while there are // cusps. Without them the caveat takes its place rather than sitting // beside it and contradicting it. this.hasCusps && data.houseSystem ? html`${this.t('{{system}} houses', { system: data.houseSystem })}` : html`${this.t('Equal sectors from the Ascendant, no house cusps in this response')}` } ${ aspects.length > 0 ? html`${this.t('Harmonious aspects')} ${this.t('Challenging aspects')}` : nothing }
${this.renderPositionDetails(data)} ${this.renderDetails()} ${ // Ungated on purpose. The figures are chart geometry, not a reading, and // only the paragraph inside each card answers to hide-readings. Do NOT // wrap this call in the flag: the reasoning, and the escape hatch for a // page that wants the block gone anyway, are on renderPatterns. this.renderPatterns() } ${this.renderAspectDetails(aspects)} ${this.renderInterpretations()}
`; } private renderWheel(planets: PlanetEntry[], aspects: AspectEntry[]) { return this.viewport.renderChartViewport( html` ${this.t('Natal chart wheel')} ${this.t('Twelve zodiac sign segments around a circular wheel. Planet glyphs are placed at their ecliptic longitudes. Aspect lines connect related planets.')} ${this.renderTicks()} ${this.renderSpokes()} ${this.renderSigns()} ${this.renderHouseNumbers()} ${this.renderCuspDegrees()} ${this.renderAspects(planets, aspects)} ${this.renderPlanets(planets)} ${this.renderAngles()} `, this.t(this.heading), this.translator, ); } /** * Planet-by-planet aspect grid: the lower-triangular matrix astrologers read * alongside the wheel. Each filled cell shows the aspect glyph colored by * nature, with the exact orb in the SVG-free `` tooltip. */ private renderAspectGrid(planets: PlanetEntry[], aspects: AspectEntry[]) { // Both values per body, deliberately: `name` is the English one the pairing // and the glyph table are keyed on, `label` is the one a reader sees. The // label is rendered verbatim, never through `capitalize`, which lowercases // the rest of the string and made a tooltip read "North node"; the pairing // folds through `lookupKey`, the same normalizer the glyph table uses. const bodies = planets.map((p) => ({ name: p.name, label: display(p, 'name'), })); const pairKey = (a: string, b: string) => [lookupKey(a), lookupKey(b)].sort().join('|'); const byPair = new Map<string, AspectEntry>(); for (const a of aspects) byPair.set(pairKey(a.planet1, a.planet2), a); if (bodies.length === 0) return html`<p class="roxy-empty" role="status">${this.t('No planets to grid')}</p>`; return html`<div class="grid-scroll" part="section aspects table aspect-grid"> <table class="aspect-grid"> <caption class="roxy-sr-only"> ${this.t('Planet by planet aspect grid: the aspect each pair of planets forms, read from the planet naming the row across to the planet naming the column.')} </caption> <thead> <tr> <td></td> ${bodies.slice(0, -1).map((b) => { const g = planetGlyph(b.name) ?? b.label; return html`<th scope="col" title=${b.label}>${g}</th>`; })} </tr> </thead> <tbody> ${bodies.slice(1).map((row, ri) => { const rowGlyph = planetGlyph(row.name) ?? row.label; // Row i (1-based) pairs with columns 0..i-1. return html`<tr> <th scope="row" title=${row.label}>${rowGlyph}</th> ${bodies.slice(0, ri + 1).map((col) => { const a = byPair.get(pairKey(row.name, col.name)); if (!a) return html`<td class="empty"></td>`; const name = normalizeAspect(a); // Every aspect the API returns has a glyph, and a miss falls // back to the full aspect NAME. Never to initials or a slice: // an invented two-letter code in a one-glyph cell reads as a // deliberate abbreviation, which is how `sesquiquadrate` // once shipped rendering as the literal text `ses`. const sym = aspectSymbol(name) ?? display(a, 'type', formatAspectName(a)); const cls = ASPECT_CLASS[name] ?? 'aspect-other'; const orb = formatNumber(this.effectiveLang(), a.orb, 1); return html`<td class=${`cell ${cls}`} title=${`${row.label} ${display(a, 'type', name)} ${col.label}${orb ? ` (${this.t('Orb')} ${orb}°)` : ''}`}> <span class="asp">${sym}</span> </td>`; })} ${bodies.slice(ri + 1, -1).map(() => html`<td class="empty"></td>`)} </tr>`; })} </tbody> </table> </div>`; } /** * The angle marks, fanned so two of them never print over each other. * * @remarks * Part of Fortune and the Vertex land wherever the chart puts them, and either * can fall within a couple of degrees of an axis, which is close enough for two * three-letter labels on one ring to become a single unreadable mash. They go * through the same {@link fanOut} the bodies do, so a crowded label steps * aside while its TICK stays at the true longitude and a leader joins the two. * The ring has about fourteen user units of margin left inside the viewBox, so * a crowded label cannot be pushed outward; it moves along the ring instead. */ private renderAngles() { const asc = this.getAscendant(); const mc = this.getMidheaven(); // ASC/DESC and MC/IC are exact axes; DESC and IC are the opposite points. const marks: Array<{ longitude: number; label: string }> = [ { longitude: asc, label: this.t('ASC') }, { longitude: oppositePoint(asc), label: this.t('DSC') }, ]; if (mc !== null) { marks.push({ longitude: mc, label: this.t('MC') }); marks.push({ longitude: oppositePoint(mc), label: this.t('IC') }); } const pof = this.data?.partOfFortune?.longitude; if (typeof pof === 'number') { marks.push({ longitude: normalizeLongitude(pof), label: this.t('PoF') }); } const vertex = this.data?.vertex?.longitude; if (typeof vertex === 'number') { marks.push({ longitude: normalizeLongitude(vertex), label: this.t('Vtx'), }); } return fanOut( marks, (m) => m.longitude, arcSeparation(ANGLE_LABEL_WIDTH, ANGLE_LABEL_R), ).map(({ item, longitude, displayLongitude }) => this.renderAngleMark(longitude, displayLongitude, item.label), ); } private renderAngleMark( longitude: number, displayLongitude: number, label: string, ) { // The tick is the claim about position, so it stays at the true longitude; // the label is only a name for it and may step aside. A leader appears only // when the two part company, so an uncrowded chart draws exactly what it // drew before. const angle = this.toAngle(longitude); const labelAngle = this.toAngle(displayLongitude); const tickInner = polarToCartesian(CENTER, CENTER, OUTER_R, angle); const tickOuter = polarToCartesian(CENTER, CENTER, ANGLE_TICK_R, angle); const labelPos = polarToCartesian( CENTER, CENTER, ANGLE_LABEL_R, labelAngle, ); const moved = Math.abs(displayLongitude - longitude) > 0.5; const leaderEnd = polarToCartesian( CENTER, CENTER, ANGLE_LABEL_R - 8, labelAngle, ); return svg` <g> <line class="angle-tick" x1=${tickInner.x} y1=${tickInner.y} x2=${tickOuter.x} y2=${tickOuter.y} /> ${ moved ? svg`<line class="angle-tick" x1=${tickOuter.x} y1=${tickOuter.y} x2=${leaderEnd.x} y2=${leaderEnd.y} />` : nothing } <text class="angle-marker" x=${labelPos.x} y=${labelPos.y} text-anchor="middle" dominant-baseline="central">${label}</text> </g> `; } private renderSpokes() { // Draw a spoke at each real house cusp longitude so Placidus / Koch // unequal houses render correctly. Fall back to 12 equal spokes from the // Ascendant only when the response carries no houses array, which the // accessible name and the legend caveat both declare. const houses = this.data?.houses ?? []; const cuspLongitudes = this.hasCusps ? houses.map((h) => h.longitude) : Array.from({ length: 12 }, (_, i) => this.getAscendant() + i * 30); return cuspLongitudes.map((lon) => { const angle = this.toAngle(lon); const start = polarToCartesian(CENTER, CENTER, HOUSE_R, angle); const end = polarToCartesian(CENTER, CENTER, OUTER_R, angle); return svg`<line class="wheel-line" x1=${start.x} y1=${start.y} x2=${end.x} y2=${end.y} stroke-width="0.8" />`; }); } private renderSigns() { return SIGNS_ORDER.map((sign, i) => { const angle = this.toAngle(i * 30 + 15); const pos = polarToCartesian(CENTER, CENTER, SIGN_R, angle); return svg`<text class="sign-glyph" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${signGlyph(sign) ?? ''}</text>`; }); } private renderHouseNumbers() { const houses = this.data?.houses ?? []; // Place each house number at the angular midpoint between its cusp and // the next, so the label sits inside the house even when houses are // unequal. Fall back to equal 30-degree sectors when houses are absent. if (this.hasCusps) { return houses.map((house, i) => { const next = houses[(i + 1) % 12]; const mid = arcMidpoint( house.longitude, next ? next.longitude : house.longitude + 30, ); const pos = polarToCartesian( CENTER, CENTER, HOUSE_R - 12, this.toAngle(mid), ); return svg`<text class="house-num" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${house.number}</text>`; }); } const ascendant = this.getAscendant(); return Array.from({ length: 12 }, (_, i) => { const angle = this.toAngle(ascendant + i * 30 + 15); const pos = polarToCartesian(CENTER, CENTER, HOUSE_R - 12, angle); const houseNum = i + 1; return svg`<text class="house-num" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${houseNum}</text>`; }); } /** * Degree ticks on the outer zodiac band: a short mark every 5 degrees and a * longer one on each 30-degree sign cusp, so the wheel reads like a * reference-grade chart rather than a bare ring of glyphs. */ private renderTicks() { const ticks = []; for (let deg = 0; deg < 360; deg += 5) { const angle = this.toAngle(deg); const isMajor = deg % 30 === 0; const inner = isMajor ? SIGN_R - 14 : OUTER_R - 5; const a = polarToCartesian(CENTER, CENTER, inner, angle); const b = polarToCartesian(CENTER, CENTER, OUTER_R, angle); ticks.push( svg`<line class=${isMajor ? 'tick tick-major' : 'tick'} x1=${a.x} y1=${a.y} x2=${b.x} y2=${b.y} stroke-width=${isMajor ? 1 : 0.5} />`, ); } return ticks; } /** * Degree-and-minute label printed next to each house cusp on the wheel, so * the exact cusp position is readable without leaving the chart. */ private renderCuspDegrees() { const houses = this.data?.houses ?? []; if (!this.hasCusps) return nothing; return houses.map((house) => { const angle = this.toAngle(house.longitude); const pos = polarToCartesian(CENTER, CENTER, HOUSE_R + 9, angle); return svg`<text class="cusp-deg" x=${pos.x} y=${pos.y} text-anchor="middle" dominant-baseline="central">${formatWheelDegree(house.longitude)}</text>`; }); } /** * The bodies, fanned so a stellium stays readable. * * @remarks * Conjunctions inside a few degrees are the norm rather than the exception, so * every glyph is pushed forward only as far as it takes to clear its neighbour * and a leader runs back to the body's TRUE longitude on the rim. The * separation is derived from the radius each mark is drawn at rather than * fixed. The degree label is about twice as wide as the glyph, so it takes * two rows: a label that would touch the one before it drops to the inner * row, which is what lets the fan clear the GLYPHS and no more, so a stellium * stays near its houses with every degree still readable. * * Below the phone breakpoint the label is the whole degree, the rule the * transit wheel applies at every width: fourteen `dd°mm'` labels at the * compact size ask for more arc than the ring has, so fanning them would * carry every body away from its position to fit them. The minute stays in * the tooltip. */ private renderPlanets(planets: PlanetEntry[]) { const compact = this.type.size.degree > NATAL_TYPE_SIZES.degree; const glyphSize = GLYPH_EM * this.type.size.glyph; // A glyph's ink is about as tall as it is wide. const glyphBox = { width: glyphSize, height: glyphSize }; const labelBox = (retro: boolean) => ({ width: ((compact ? WHOLE_DEG_EM : DEG_LABEL_EM) + (retro ? RETRO_MARK_EM : 0)) * this.type.size.degree, height: DEG_LABEL_INK_EM * this.type.size.degree, }); // The outer row clears the glyph along its own ray; the inner row steps in // but never past the hub, which near the sides leaves it on the outer row. const labelRadius = (p: PlanetEntry, angle: number, row: number) => { const box = labelBox(p.isRetrograde === true); const outer = PLANET_R - rayClearance(angle, glyphBox, box, GLYPH_LABEL_GAP); return row === 0 ? outer : Math.min( outer, Math.max( outer - DEG_LABEL_STAGGER, HUB_R + GLYPH_LABEL_GAP + rayReach(angle, box), ), ); }; const glyphSeparation = arcSeparation(glyphSize, PLANET_R); // With two rows the fan only has to hold every SECOND label a label width // apart, so one step of half the widest label does for the whole ring: a // per-pair step would let a narrow label land two steps past a wide one // with no row left to take it. const widest = labelBox(true); const separation = Math.max( glyphSeparation, arcSeparation( widest.width, PLANET_R - rayClearance(90, glyphBox, widest, GLYPH_LABEL_GAP), ) / 2, ); const fanned = fanOut(planets, (p) => p.longitude, separation); const rows = staggerRows(fanned, ({ item, displayLongitude }, row) => { const angle = this.toAngle(displayLongitude); return { ...polarToCartesian( CENTER, CENTER, labelRadius(item, angle, row), angle, ), ...labelBox(item.isRetrograde === true), }; }); return fanned.map((placed, i) => { const { item: p, longitude: trueLon, displayLongitude: displayLon, } = placed; const trueAngle = this.toAngle(trueLon); const displayAngle = this.toAngle(displayLon); const glyphPos = polarToCartesian(CENTER, CENTER, PLANET_R, displayAngle); const degPos = polarToCartesian( CENTER, CENTER, labelRadius(p, displayAngle, rows[i] ?? 0), displayAngle, ); const rimPos = polarToCartesian(CENTER, CENTER, OUTER_R - 4, trueAngle); const leaderInner = polarToCartesian( CENTER, CENTER, PLANET_R + 8, displayAngle, ); // The glyph is looked up on the canonical English name and its fallback is // the localized one, because the fallback is TEXT the reader is left with. const label = display(p, 'name'); const glyph = planetGlyph(p.name) ?? label; const retro = p.isRetrograde === true; const position = formatWheelDegree(p.longitude); const degLabel = compact ? `${longitudeToSignPosition(p.longitude).degree}°` : position; const offset = Math.abs(displayLon - trueLon) > 0.5; return svg`<g> ${ offset ? svg`<line class="planet-leader" x1=${rimPos.x} y1=${rimPos.y} x2=${leaderInner.x} y2=${leaderInner.y} />` : nothing } <text class="planet-glyph" x=${glyphPos.x} y=${glyphPos.y} text-anchor="middle" dominant-baseline="central"><title>${label}${retro ? ` ${this.t('retrograde')}` : ''} - ${position} ${display(p, 'sign')}${glyph} ${degLabel}${retro ? svg` ℞` : nothing} `; }); } private renderPositionDetails(data: WheelChart) { return html`
${this.t('Chart details')}
`; } private renderDetails() { const summary = this.data?.summary; const ai = this.data?.aspectsInterpretation; if (!summary && !ai) return nothing; // Pairs, not names: the glyph beside each retrograde body is looked up on the // English value while the pill prints the localized one, and the two arrays // the API returns are index-aligned, which is what makes that possible. const retrogrades = displayList(summary, 'retrogradePlanets'); return html`
${ summary?.dominantElement || summary?.dominantModality ? html`
${summary.dominantElement ? html`${this.t('Dominant element')}: ${display(summary, 'dominantElement')}` : nothing} ${summary.dominantModality ? html`${this.t('Dominant modality')}: ${display(summary, 'dominantModality')}` : nothing}
` : nothing } ${ ai ? html`
${this.t('Harmonious: {{count}}', { count: ai.harmonious })} ${this.t('Challenging: {{count}}', { count: ai.challenging })} ${this.t('Neutral: {{count}}', { count: ai.neutral })}
` : nothing } ${ retrogrades.length > 0 ? html`
${retrogrades.map(({ value, label }) => { // `?? ''` and never a slice: the full name is printed right // beside the glyph, so a miss must drop the glyph, not invent // one. This is the site that shipped `No North Node R`, // `So South Node R` and `Bl Black Moon Lilith` on a live chart. const glyph = planetGlyph(value) ?? ''; return html`${glyph} ${label} R`; })}
` : nothing } ${ // Prose about the aspect balance, not the balance itself: the counts // above it are the data and stay. ai?.summary && !this.hideReadings ? html`

${ai.summary}

` : nothing } ${this.renderElementModalityGrid()}
`; } /** The all-body cross-tab and the API planet-only distributions keep their populations separate. */ private renderElementModalityGrid() { const planets = this.getPlanets(); if (planets.length === 0) return nothing; const ELEMENTS = ['Fire', 'Earth', 'Air', 'Water'] as const; const MODALITIES = ['Cardinal', 'Fixed', 'Mutable'] as const; // Header text, keyed by the canonical bucket name. Written out as literal // `t()` calls rather than `this.t(el)` so the static scan in // `tests/i18n.test.ts` can see every one of them: a dynamic key is exactly // the shape that ships a string no catalogue carries. const ELEMENT_LABEL: Record = { Fire: this.t('Fire'), Earth: this.t('Earth'), Air: this.t('Air'), Water: this.t('Water'), }; // The column is three glyphs wide, so it shows an abbreviation the // CATALOGUE supplies and carries the full word as its `title`. Never a // substring of the translated word: `slice(0, 3)` counts UTF-16 units with // no idea where the word ends, so Spanish read `Fij` and Devanagari split a // matra off its consonant. `tokens.test.ts` fails on any small slice now. const MODALITY_LABEL: Record = { Cardinal: { abbr: this.t('Car'), full: this.t('Cardinal') }, Fixed: { abbr: this.t('Fix'), full: this.t('Fixed') }, Mutable: { abbr: this.t('Mut'), full: this.t('Mutable') }, }; const order = SIGNS_ORDER as readonly string[]; const summary = this.data?.summary; const cells: Record> = {}; for (const el of ELEMENTS) cells[el] = { Cardinal: [], Fixed: [], Mutable: [] }; let placed = 0; for (const p of planets) { const idx = order.indexOf(capitalize(p.sign ?? '')); if (idx < 0) continue; const el = ELEMENTS[idx % 4]; const mod = MODALITIES[idx % 3]; // The cell shows the glyph alone, so a miss falls back to the full name: // wide and obviously wrong, rather than a plausible two-letter code. const glyph = planetGlyph(p.name) ?? display(p, 'name'); cells[el]?.[mod]?.push(glyph); placed++; } if (placed === 0) return nothing; const colTotal = (m: string) => ELEMENTS.reduce((s, el) => s + (cells[el]?.[m]?.length ?? 0), 0); // Same scroll box as the aspect grid beside it. A cell holds a joined list // of glyphs, so it is normally tiny, but a body with no glyph renders its // FULL name there by design, and the table has to carry that without // dragging the card out with it. `.grid-scroll` pairs `overflow-x: auto` // with `min-width: 0`, and it is the `min-width` half that actually lets a // grid item scroll instead of growing to fit. const distribution = [ ...Object.entries(summary?.elementDistribution ?? {}).map( ([key, count]) => ({ label: ELEMENT_LABEL[capitalize(key)] ?? key, count, }), ), ...Object.entries(summary?.modalityDistribution ?? {}).map( ([key, count]) => ({ label: MODALITY_LABEL[capitalize(key)]?.full ?? key, count, }), ), ]; const planetCount = Object.values( summary?.elementDistribution ?? {}, ).reduce((sum, count) => sum + count, 0); return html`${ distribution.length ? html`

${this.t('Element and modality distribution')}

${this.t('{{count}} planets', { count: planetCount })}

${distribution.map(({ label, count }) => html`${label}: ${formatNumber(this.effectiveLang(), count, 0)}`)}
` : nothing }
${MODALITIES.map( (m) => html``, )} ${ELEMENTS.map((el) => { const rowTotal = MODALITIES.reduce( (s, m) => s + (cells[el]?.[m]?.length ?? 0), 0, ); return html` ${MODALITIES.map( (m) => html``, )} `; })} ${MODALITIES.map( (m) => html``, )}
${this.t('All {{count}} bodies in the chart, placed by sign', { count: placed })}
${MODALITY_LABEL[m]?.abbr ?? m}${this.t('Total')}
${ELEMENT_LABEL[el] ?? el}${(cells[el]?.[m] ?? []).join(' ')}${rowTotal}
${this.t('Total')}${colTotal(m)}${placed}
`; } /** * Detected multi-planet configurations. Each card names the figure, tags the element or modality it pivots on, flags a dissociate (out-of-sign) figure, and puts the apex planet first with its own chip, because the apex is the point the whole configuration discharges through. * * @remarks * A pattern carries no localized partner for its `name`, `element`, `modality` or its `planets` list, so all four render English on every page. Do NOT translate the chips by looking each body up in `planets[].nameLocalized`: the response is the authority for its own vocabulary, and a second translation of the same fact assembled here is exactly what can end up disagreeing with the wheel beside it. * * **This block is DATA, so `hide-readings` thins it rather than removing it, and that split is deliberate.** A T-Square is a geometric fact about where the bodies sit, exactly like the aspect rows and the house cusps: the figure, its element and modality, its tightness and its planets are all measurements, and the only reading in the card is the paragraph, which {@link RoxyNatalChart.renderPattern} already drops. The line is the one the library states everywhere else and the one the docs promise: "wheels, maps, tables, grids, legends, badges and every number stay, the interpretive prose goes". `` renders this identical `patterns` payload and makes the identical cut, so gating the whole block here would make the same figure appear and disappear depending on which component a page reached for. * * **A page that wants the block gone entirely has a lever, and it is not this flag.** The section carries `part="section patterns"`, so `roxy-natal-chart::part(patterns) { display: none }` removes it. That is the documented answer for dropping a block of DATA, the same way `::part(readings)` styles the interpretation; `hide-readings` is the answer for prose, and stretching it to cover measurements would leave a practitioner with no way to publish the figures without the words. */ private renderPatterns() { const patterns = this.data?.patterns ?? []; if (patterns.length === 0) return nothing; const sorted = [...patterns].sort( (a, b) => (PATTERN_ORDER[a.kind] ?? 9) - (PATTERN_ORDER[b.kind] ?? 9) || (b.tightness ?? 0) - (a.tightness ?? 0), ); return html`

${this.t('Chart patterns')}

${sorted.map((p) => this.renderPattern(p))}
`; } private renderPattern(p: PatternEntry) { const planets = p.planets ?? []; // Apex first: the spec already orders it first for Kite, T-Square, and Yod, // but sorting here keeps the chip row honest for any future pattern kind. const ordered = p.apex ? [...planets].sort((a, b) => Number(b === p.apex) - Number(a === p.apex)) : planets; return html`
${p.name} ${p.element ? html`${p.element}` : nothing} ${p.modality ? html`${p.modality}` : nothing} ${ p.dissociate ? html`${this.t('Dissociate')}` : nothing } ${ // Math.round, not formatNumber(this.effectiveLang(), x, 0): that helper strips trailing // zeros, so a 100% tight pattern renders as "1%". typeof p.tightness === 'number' ? html`${this.t('{{percent}} tight', { percent: formatPercent(this.effectiveLang(), p.tightness, 0) })}` : nothing }
${ordered.map((name) => { const glyph = planetGlyph(name); const isApex = Boolean(p.apex) && name === p.apex; return html` ${glyph ? html`` : nothing}${name}${isApex ? html`${this.t('apex')}` : nothing} `; })}
${ // The figure, its planets and its tightness are the finding; the // paragraph is the reading of it. p.interpretation && !this.hideReadings ? html`

${p.interpretation}

` : nothing }
`; } private renderAspectDetails(aspects: AspectEntry[]) { if (aspects.length === 0) return nothing; return html`
${this.t('Aspects ({{count}})', { count: aspects.length })}
`; } private renderInterpretations() { const sections: InterpSection[] = this.getPlanets() .filter((p) => p.interpretation) .map((p) => { const interp = p.interpretation!; const glyph = planetGlyph(p.name) ?? ''; const label = display(p, 'name'); const lead = interp.summary || interp.detailed || ''; // `detailed` only becomes a second paragraph when `summary` already // took the lead line, so a response carrying one or the other never // prints the same prose twice. const detail = interp.summary ? interp.detailed : undefined; return { label: `${glyph} ${label}`.trim(), // The same degree and minute the wheel prints beside the glyph. aside: `${display(p, 'sign')} ${formatWheelDegree(p.longitude)}`, body: lead, extra: html`${detail ? html`

${detail}

` : nothing} ${renderKeywordChips(interp.keywords)}`, }; }); return this.renderInterpretation( sections, 'natal-planet-readings', 'Planet readings', ); } private renderAspects(planets: PlanetEntry[], aspects: AspectEntry[]) { // Keyed on the canonical English name at BOTH ends, because this map is how // an aspect finds the two longitudes its line is drawn between. The localized // names go in the `` and nowhere near the key. const planetMap = new Map<string, number>(); for (const p of planets) { if (typeof p.longitude !== 'number') continue; const name = lookupKey(p.name); if (name) planetMap.set(name, p.longitude); } return aspects.map((a) => { const l1 = planetMap.get(lookupKey(a.planet1)); const l2 = planetMap.get(lookupKey(a.planet2)); if (l1 === undefined || l2 === undefined) return nothing; const p1 = polarToCartesian(CENTER, CENTER, HUB_R - 2, this.toAngle(l1)); const p2 = polarToCartesian(CENTER, CENTER, HUB_R - 2, this.toAngle(l2)); const aspectName = normalizeAspect(a); const aspectClass = ASPECT_CLASS[aspectName] ?? 'aspect-other'; const orbLabel = formatNumber(this.effectiveLang(), a.orb, 1); return svg`<line class=${`aspect ${aspectClass}`} style=${aspectLineStyle(a)} x1=${p1.x} y1=${p1.y} x2=${p2.x} y2=${p2.y}><title>${display(a, 'planet1')} ${display(a, 'type', aspectName)} ${display(a, 'planet2')}${orbLabel ? ` (${this.t('Orb')} ${orbLabel}°)` : ''}`; }); } } declare global { interface HTMLElementTagNameMap { 'roxy-natal-chart': RoxyNatalChart; } }