import { css, html, nothing, svg } from 'lit'; import { transitWheelEndpoints } from '../generated/endpoint-defaults.js'; import './aspects-table.js'; import './western-planets-table.js'; import { property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { planetGlyph, SIGNS_ORDER, signGlyph } from '../tokens/index.js'; import type { CalculateTransitAspectsResponse, 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, type MarkSize, normalizeLongitude, oppositePoint, polarToCartesian, rayClearance, } from '../utils/degree.js'; import { disclosureStyles } from '../utils/disclosure.js'; import { ASPECT_CLASS, formatAspectName, formatDateTime, formatNumber, normalizeAspect, } from '../utils/format.js'; import { display } from '../utils/localized.js'; import { capitalize } from '../utils/string.js'; import { MeasuredType } from '../utils/type-metrics.js'; type Body = CalculateTransitAspectsResponse['transitPlanets'][number]; type TransitAspect = CalculateTransitAspectsResponse['aspects'][number]; type TransitSummary = CalculateTransitAspectsResponse['summary']; /** * Radii, outermost first. Every one is explicit rather than derived from its * neighbour, because the two rings of glyphs and their two bands of degree * labels have to interleave without touching, and an offset-from-the-last-ring * scheme hides which pair is actually the tight one. */ const SIZE = 640; const CENTER = SIZE / 2; /** ASC/DSC 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, or the axis text clips on it. */ const AXIS_TICK_R = 212; const AXIS_LABEL_R = 226; /** Outer rim of the zodiac band. */ const OUTER_R = 204; /** Sign glyphs, and the inner edge of the band that holds them. */ const SIGN_R = 186; const BAND_R = 168; /** Transiting bodies sit outside the zodiac with their degree labels farther outward. */ const TRANSIT_R = 250; /** Natal bodies: the INNER ring, the chart being transited. Degree labels sit inward, so the two bands of numbers never meet. */ const NATAL_R = 142; /** The central aspect circle projects true longitudes clear of the natal degree labels. */ const HUB_R = 78; /** House numbers occupy the band between natal bodies and signs, using supplied cusps. */ const HOUSE_NUM_R = 166; /** * 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 TRANSIT_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. The glyph is on the * larger radius and the whole-degree label on a smaller one, so which of the two * binds depends on the ring, and a retrograde mark widens a label by an em and a * quarter, which is why the separation is settled per pair. */ const GLYPH_EM = 0.95; const WHOLE_DEG_EM = 1.85; const RETRO_MARK_EM = 1.25; /** The ink of a degree label is this tall. */ 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 offsets are measured from the ring, signed so the leader always runs into the gap between the two rings. */ const LEADER_TICK = 8; const LEADER_FOOT = 4; /** * Glyph for an API body, or the name a reader sees when the library has none: the SVG ring position is the sole identifier for that body, so a miss must stay visibly wrong rather than a plausible fabricated abbreviation. * * Two arguments because the two halves answer to different languages. `name` is the canonical English value the glyph table is keyed on and must stay English; `label` is what is left ON SCREEN when that lookup misses, so it is the localized one. */ const glyphFor = (name: string, label: string): string => planetGlyph(name) ?? label; /** Bodies keyed by their canonical ENGLISH name, so an aspect or a table row can find a longitude without rescanning the array. The API keeps `name` English in every language for exactly this, and keying on `nameLocalized` would resolve nothing on a translated page. */ const byName = (list: Body[]): Map => { const m = new Map(); for (const p of list) m.set(capitalize(p.name), p); return m; }; /** * Natal and transit bi-wheel: the natal chart on the inner ring, the transiting * bodies on the outer ring, and a line for every transit-to-natal aspect the * response returns. Pass `data` from /astrology/transit-aspects. * * @remarks * **Nothing astrological is computed here.** Every aspect line is drawn from an * entry in the response `aspects` array; the only arithmetic is placing a * longitude on a circle. `planet1` is the TRANSITING body and `planet2` the * NATAL one, which was settled empirically rather than from the field * description because reading them the other way round still draws a * plausible-looking wheel: against a live response every one of the 70 returned * orbs reconciles with the transit-to-natal separation, and only 4 with the * reverse. * * **The response carries real houses, and it did not always.** Every body comes * back with a `house` read against the NATAL cusps (a transiting body reports the * natal house it is currently passing through) plus a top-level `houseSystem`. * The fourteen natal house numbers reconcile body * for body with /astrology/natal-chart for the same birth data, and requesting * `whole-sign` moves them. An earlier revision of the API did return `1` for * every body, and the comment saying so outlived the fix and went on justifying a * missing House column for months. The numbers are rendered, in their own * columns, labelled with the system the response names. * * **The GEOMETRY arrived next, and the wheel now draws itself.** The response also * carries the twelve natal cusps as `houses` and the natal `ascendant`, the same * twelve the house numbers above were read against, so the sector ring and the * real horizon come out of the one call the page already made. That closed the * house-less bi-wheel every embed drew through WordPress, /embed and the CDN * widgets, none of which can compose a second call. {@link RoxyTransitWheel.houses} * and {@link RoxyTransitWheel.ascendant} remain as OVERRIDES and keep winning * where a page sets them, which is what a stored response predating the fields, * or a trimming proxy, needs. * * **Nothing is ever derived, whichever source it came from.** A payload with * neither field still fixes 0 degrees Aries on the left horizon, says so in the * legend and in the SVG description rather than leaving a reader to discover it, * and draws no house divisions at all. Equal sectors derived from an Ascendant * and labelled with a quadrant system would replace one false statement with a * worse one. Every cusp on this wheel is a longitude some source stated, placed * on a circle, never one this component worked out. */ @customElement('roxy-transit-wheel') export class RoxyTransitWheel extends RoxyDataElement { static endpoints = transitWheelEndpoints; private readonly viewport = new ChartViewport(this); static styles = [ baseStyles, chartViewportStyles, aspectLineStyles, disclosureStyles, css` roxy-aspects-table::part(card), roxy-western-planets-table::part(card) { border: 0; padding: 0; box-shadow: none; } .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 box inside actually scroll. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); } .head { display: flex; justify-content: space-between; align-items: baseline; 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; } .subtitle { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-sm, 0.875rem); margin: 0; } svg { display: block; width: 100%; max-width: var(--roxy-chart-max-width, 760px); 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); } .natal-glyph { fill: var(--roxy-accent-ink, #b45309); font-size: 18px; font-weight: 600; font-family: var(--roxy-font-sans); } .transit-glyph { fill: var(--roxy-info, #0284c7); font-size: 18px; font-weight: 600; font-family: var(--roxy-font-sans); } /* A leader ties a displaced glyph back to its true longitude. Thin and * ring-coloured, and drawn in the gap between the rings, so it reads as * part of its own ring rather than as another aspect line. */ .leader { stroke: currentColor; stroke-width: 0.6; opacity: 0.6; } .leader.natal-glyph { stroke: var(--roxy-accent-ink, #b45309); } .leader.transit-glyph { stroke: var(--roxy-info, #0284c7); } .planet-deg { fill: var(--roxy-muted, #71717a); font-size: 10px; font-family: var(--roxy-font-sans); } .planet-deg .retro { fill: var(--roxy-danger, #dc2626); } /* 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-glyph, .natal-glyph, .transit-glyph { font-size: 26px; } .planet-deg { font-size: 14px; } .house-num { font-size: 12px; } } .tick { stroke: var(--roxy-border, #e4e4e7); } .tick-major { stroke: var(--roxy-secondary, #475569); } /* Dashed, so a house cusp is never mistaken for one of the solid sign * boundaries it crosses. Only ever drawn from supplied cusps. */ .house-cusp { stroke: var(--roxy-secondary, #475569); stroke-width: 1; stroke-dasharray: 3 3; } .house-num { fill: var(--roxy-secondary, #475569); font-size: 9px; font-family: var(--roxy-font-sans); } .axis-tick { stroke: var(--roxy-accent-ink, #b45309); stroke-width: 1.5; } .axis-label { fill: var(--roxy-accent-ink, #b45309); font-size: 10px; font-weight: 700; font-family: var(--roxy-font-sans); letter-spacing: 0.04em; } .swatch { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } .swatch--natal { background: var(--roxy-accent-ink, #b45309); } .swatch--transit { background: var(--roxy-info, #0284c7); } .swatch--harmonious { background: var(--roxy-success, #16a34a); } .swatch--challenging { background: var(--roxy-danger, #dc2626); } .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); } .summary-pills { display: flex; flex-wrap: wrap; gap: var(--roxy-space-sm, 0.5rem); margin-bottom: var(--roxy-space-sm, 0.5rem); } .pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px var(--roxy-space-sm, 0.5rem); border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); font-weight: var(--roxy-weight-bold, 600); border: 1px solid currentColor; } .pill--muted { color: var(--roxy-fg, #0a0a0a); background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 60%, transparent); } .pill--success { color: var(--_success-fg); background: color-mix(in srgb, var(--roxy-success, #16a34a) 10%, transparent); } .pill--danger { color: var(--_danger-fg); background: color-mix(in srgb, var(--roxy-danger, #dc2626) 10%, transparent); } .strongest { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; 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); font-size: var(--roxy-text-sm, 0.875rem); } .strongest .label { 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); } .strongest .meta { margin-left: auto; color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); font-variant-numeric: tabular-nums; } .glyph { font-size: 1.1em; line-height: 1; } .nature-badge { display: inline-block; padding: 1px 8px; border-radius: 9999px; font-size: var(--roxy-text-xs, 0.75rem); font-weight: 600; text-transform: capitalize; } .nature-badge.harmonious { background: color-mix(in srgb, var(--roxy-success, #16a34a) 12%, transparent); color: var(--_success-fg); } .nature-badge.challenging { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 12%, transparent); color: var(--_danger-fg); } .nature-badge.neutral { background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 60%, transparent); color: var(--roxy-fg, #0a0a0a); } /* min-width: 0 so the scroll box can shrink below its content. A grid * item defaults to min-width: auto and grows to fit instead of scrolling. */ .scroll { overflow-x: auto; min-width: 0; -webkit-overflow-scrolling: touch; } 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; white-space: nowrap; } thead th { color: var(--roxy-muted, #71717a); text-transform: uppercase; font-size: var(--roxy-text-xs, 0.75rem); letter-spacing: 0.06em; } th { font-weight: var(--roxy-weight-bold, 600); } td { font-variant-numeric: tabular-nums; } .retro-badge { color: var(--_danger-fg); font-weight: var(--roxy-weight-bold, 600); margin-left: 0.25rem; } `, ]; /** Heading above the wheel. The default is translated; a heading the page sets is passed through the catalogue too, so a host that supplies its own English wording still gets it localized when one is registered for it. */ @property({ type: String }) heading = 'Transits'; /** * Natal Ascendant as an ecliptic longitude in degrees (0-360), to override the * one the response carries. * * @remarks * Leave it unset on a live /astrology/transit-aspects response: that endpoint * returns the natal `ascendant` and the wheel reads it, so the horizon is * right with no page wiring at all. Set it only to orient the wheel on a * DIFFERENT horizon than the response describes, or to give one to a payload * that carries none. Either way it ROTATES the wheel so the given longitude * falls on the left horizon and draws the ASC/DSC axis. It draws no house * divisions, because a rotation is not a cusp; that is what * {@link RoxyTransitWheel.houses} is for. With neither the prop nor a payload * ascendant the wheel falls back to the honest default: 0 degrees Aries on * the left. */ @property({ type: Number }) ascendant?: number; /** * The twelve natal house cusps, to override the ones the response carries. * Takes the /astrology/natal-chart `houses` array verbatim, or twelve bare * cusp longitudes in house order. * * @remarks * Leave it unset on a live /astrology/transit-aspects response: that endpoint * returns the same twelve natal cusps its house numbers are read against, so * the sector ring draws itself. Set it to draw a different cusp set than the * response carries, or to give one to a payload that carries none (a stored * response predating the field, or a proxy that trims it). Supplying either * source draws the twelve cusp lines and their numbers and, unless an * ascendant is also available, rotates the first cusp onto the left horizon. * * Both accepted shapes exist because the natural source is a natal-chart * response a host already holds, and passing `chart.houses` straight through * has to work; twelve numbers is the terser form for a host that stored only * the longitudes. Anything that does not resolve to houses 1 to 12 with finite * longitudes is ignored outright rather than half drawn, which is the same * degrade-honestly rule the rest of this component follows: a partial house * ring is a claim about placements the wheel cannot support. */ @property({ type: Array }) houses?: NatalChartResponse['houses'] | number[]; /** 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: ['.natal-glyph', TRANSIT_TYPE_SIZES.glyph], degree: ['.planet-deg', TRANSIT_TYPE_SIZES.degree], }); /** * The Ascendant longitude the wheel orients on, or undefined. * * @remarks * The PROP wins over the payload wherever both exist: a page that passes one * has said something the response cannot know, and silently preferring the * response would make the prop dead on exactly the responses that carry the * field. */ private get effectiveAscendant(): number | undefined { for (const value of [this.ascendant, this.data?.ascendant?.longitude]) { if (typeof value === 'number' && Number.isFinite(value)) return value; } return undefined; } /** True when an ascendant is available at all, so the ASC/DSC axis is a real horizon rather than one derived from a cusp. */ private get ascendantGiven(): boolean { return this.effectiveAscendant !== undefined; } /** True when the sector ring came from the page rather than from the response, so the legend can say which. */ private get cuspsFromPage(): boolean { return Array.isArray(this.houses); } /** * The cusps to draw, normalized to houses 1 to 12 in order, or null. The prop * first, then the ones the response carries, on the same precedence rule as * {@link RoxyTransitWheel.effectiveAscendant}. * * @remarks * Sorted by house number rather than trusted in array order, and rejected * whole unless the twelve numbers are exactly 1 to 12 with finite longitudes. * A cusp set that fails either check is not a house division, and drawing part * of one would put a sector boundary where the host never claimed there was * one. A malformed PROP is rejected outright rather than falling through to * the payload: the page overrode the response on purpose, and quietly drawing * the response cusps under a bad override would be a different chart than * either source asked for. */ private get cusps(): Array<{ number: number; longitude: number }> | null { const raw = this.houses ?? this.data?.houses; if (!Array.isArray(raw) || raw.length !== 12) return null; const out = raw .map((h, i) => typeof h === 'number' ? { number: i + 1, longitude: h } : { number: Number(h?.number), longitude: Number(h?.longitude) }, ) .sort((a, b) => a.number - b.number); const complete = out.every( (c, i) => c.number === i + 1 && Number.isFinite(c.longitude), ); return complete ? out : null; } /** * Ecliptic longitude to SVG angle. 0 degrees is at 3 o'clock and positive * angles run clockwise on screen, so subtracting the longitude makes the * zodiac run counterclockwise, which is how a chart wheel is read. The 180 * offset puts the reference point on the left horizon: the supplied Ascendant * first, else the supplied first house cusp, else 0 Aries. * * The Ascendant wins because the two are not the same longitude under every * system: whole-sign puts cusp 1 at 0 degrees of the rising sign, which can be * most of a sign away from the Ascendant itself. */ private toAngle(longitude: number): number { const origin = this.effectiveAscendant ?? this.cusps?.[0]?.longitude ?? 0; return 180 + origin - longitude; } protected renderData(d: CalculateTransitAspectsResponse) { const natal = d.natalPlanets ?? []; const transit = d.transitPlanets ?? []; const aspects = d.aspects ?? []; const when = formatDateTime(this.effectiveLang(), d.transitDate); // Label then number, at every count and in every language: the catalogue // carries no plural rules, so "1 aspects" is the alternative in English and // three wrong endings are the alternative in Russian. const count = aspects.length > 0 ? this.t('Aspects to the natal chart: {{count}}', { count: aspects.length, }) : ''; // The House columns and the house-system chip both stand on the SAME fact: // that a body came back with a house. Derived once from the payload rather // than assumed from the type, so a host feeding an older captured response // loses the columns instead of printing a column of blanks under a system // name. const houses = [...natal, ...transit].some((p) => Number.isFinite(p.house)); return html`

${this.t(this.heading)}

${ when || count ? html`

${[when, count].filter(Boolean).join(' · ')}

` : nothing }
${this.renderWheel(natal, transit, aspects)} ${this.renderLegend(natal, transit, aspects, houses ? d.houseSystem : undefined)} ${this.renderSummary(d.summary)} ${this.renderPositions(natal, transit, houses)} ${this.renderPositionDetails(d)} ${this.renderAspectDetails(aspects)}
`; } /** * What sits on the left horizon, in words. Rendered as a legend chip AND as a * sentence of the SVG description, from this one getter, so the drawing and * its accessible text cannot end up claiming different orientations. */ private get orientationCaption(): string { if (this.ascendantGiven) return this.t('Ascendant on the left horizon'); if (this.cusps) return this.t('First house cusp on the left horizon'); return this.t('Sign wheel, 0° Aries on the left'); } /** * Whether the twelve sectors on the wheel are real cusps, in words, and where * they came from. Same dual use as {@link RoxyTransitWheel.orientationCaption}. * * @remarks * The source is named because the caption is provenance: a reader * reconciling this wheel against another calculator needs to know whether the * cusps are the ones the endpoint computed the house numbers against, or a set * the page substituted for them. */ private get cuspCaption(): string { if (!this.cusps) return this.t('No house cusps'); return this.cuspsFromPage ? this.t('House cusps supplied by the page') : this.t('House cusps from the response'); } private renderWheel( natal: Body[], transit: Body[], aspects: TransitAspect[], ) { return this.viewport.renderChartViewport( html` ${this.t('Natal and transit bi-wheel')} ${this.t('Twelve zodiac sign segments around a circular wheel. Natal bodies sit at their ecliptic longitudes on the inner ring and transiting bodies on the outer ring, and each line joins a transiting body to the natal body it aspects.')} ${this.orientationCaption}. ${this.cuspCaption}. ${this.renderTicks()} ${this.renderSpokes()} ${this.renderSigns()} ${this.renderHouses()} ${this.renderAspectLines(natal, transit, aspects)} ${this.renderRing(natal, NATAL_R, (angle, glyph, label) => NATAL_R - rayClearance(angle, glyph, label, GLYPH_LABEL_GAP), 'natal-glyph', this.t('Natal'), 1)} ${this.renderRing(transit, TRANSIT_R, (angle, glyph, label) => TRANSIT_R + rayClearance(angle, glyph, label, GLYPH_LABEL_GAP), 'transit-glyph', this.t('Transiting'), -1)} ${this.renderAxis()} `, this.t('Natal and transit bi-wheel'), this.translator, ); } /** A short mark every 5 degrees and a longer one on each sign cusp, so the band reads as a degree scale rather than a bare ring. */ private renderTicks() { const ticks = []; for (let deg = 0; deg < 360; deg += 5) { const angle = this.toAngle(deg); const isMajor = deg % 30 === 0; const a = polarToCartesian( CENTER, CENTER, isMajor ? BAND_R : OUTER_R - 5, angle, ); const b = polarToCartesian(CENTER, CENTER, OUTER_R, angle); ticks.push( svg``, ); } return ticks; } /** Twelve sign boundaries, drawn from the inner ring outward. Solid, and always exactly 30 degrees apart: house cusps are the dashed lines from {@link RoxyTransitWheel.renderHouses} and only exist when a cusp set is available. */ private renderSpokes() { return SIGNS_ORDER.map((_, 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``; }); } /** * The twelve house sectors, drawn ONLY from cusps some source stated: the ones * the response carries, or the {@link RoxyTransitWheel.houses} override. * * @remarks * Dashed and inset to the sign band, because the solid spokes beside them are * sign boundaries and a reader who cannot tell the two apart cannot reconcile * the chart against another calculator. Each number sits at the arc midpoint * of its own sector, so an unequal house carries its label inside itself; the * hub is the one band on this wheel with nothing else in it. * * Nothing here is derived. With no cusps available this renders nothing at all * rather than twelve equal sectors from the Ascendant, which would be a * different chart wearing the same label. * * **Known and accepted: a fanned glyph can sit one sector off.** {@link fanOut} * displaces a crowded glyph in ANGLE, so a body a degree inside a cusp can be * drawn just past it; measured on a real Capricorn stellium, the natal Sun * glyph landed in the ninth sector while the body is in the eighth. The leader * runs back to a tick at the true longitude and the House column is the * authoritative number, which is the same trade professional chart software * makes. Do not "fix" it by clamping a glyph to its sector: that would move the * mark AND still not be the position. */ private renderHouses() { const cusps = this.cusps; if (!cusps) return nothing; return cusps.map((cusp, i) => { const next = cusps[(i + 1) % 12]; const angle = this.toAngle(cusp.longitude); const start = polarToCartesian(CENTER, CENTER, HUB_R, angle); const end = polarToCartesian(CENTER, CENTER, BAND_R, angle); const label = polarToCartesian( CENTER, CENTER, HOUSE_NUM_R, this.toAngle( arcMidpoint(cusp.longitude, next?.longitude ?? cusp.longitude + 30), ), ); return svg` ${cusp.number} `; }); } private renderSigns() { return SIGNS_ORDER.map((sign, i) => { const pos = polarToCartesian( CENTER, CENTER, SIGN_R, this.toAngle(i * 30 + 15), ); return svg`${signGlyph(sign)}`; }); } /** * One ring of bodies, fanned apart only as far as legibility demands. * * @remarks * A real transit chart stacks four bodies inside a couple of degrees often * enough that drawing every glyph at its exact angle makes the inner ring * unreadable, degree labels printed over each other included. {@link fanOut} * pushes a crowded member forward and a thin leader line runs from the glyph * back to a tick at its TRUE longitude, so the drawing never claims a position * the response did not give. The separation is derived from the radius, so the * inner ring automatically asks for more degrees of arc than the outer one for * the same glyph. * * `leaderSign` points that tick outward from the natal ring and inward from * the transit ring, i.e. always into the gap between the two, so a leader is * never mistaken for one of the aspect lines crossing the middle, and * `labelRadius` is where a degree label sits along its glyph's ray. */ private renderRing( bodies: Body[], radius: number, labelRadius: (angle: number, glyph: MarkSize, label: MarkSize) => number, cls: string, kind: string, leaderSign: 1 | -1, ) { const glyphSize = GLYPH_EM * this.type.size.glyph; const glyphBox = { width: glyphSize, height: glyphSize }; const labelBox = (p: Body) => ({ 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(glyphSize, 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: Body, b: Body) => Math.max( glyphSeparation, arcSeparation( (labelBox(a).width + labelBox(b).width) / 2, labelRadius(90, glyphBox, labelBox(a)), ), ); return fanOut(bodies, (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 retro = p.isRetrograde === true; // Whole degrees on the wheel and the full degree-and-minute in the // tooltip and the positions table, matching `roxy-synastry-chart`, the // other two-ring wheel here. A `12°34'` label is roughly twice as wide as // `12°`, and on a ring this size two of them collide in a cluster the // fan-out has already separated the GLYPHS of, so the extra precision // costs the legibility of the position it is trying to state. const label = `${longitudeToSignPosition(longitude).degree}°`; const body = display(p, 'name'); const tooltip = `${kind} ${body}${retro ? ` ${this.t('retrograde')}` : ''} - ${formatWheelDegree(longitude)} ${display(p, 'sign')}`; const displaced = Math.abs(displayLongitude - longitude) > 0.5; const tick = polarToCartesian( CENTER, CENTER, radius + leaderSign * LEADER_TICK, this.toAngle(longitude), ); const foot = polarToCartesian( CENTER, CENTER, radius + leaderSign * LEADER_FOOT, angle, ); return svg` ${ displaced ? svg`` : nothing } ${tooltip}${glyphFor(p.name, body)} ${label}${retro ? svg` ℞` : nothing} `; }, ); } /** One line per returned aspect, from the transiting body on the outer ring to the natal body on the inner one. */ private renderAspectLines( natal: Body[], transit: Body[], aspects: TransitAspect[], ) { const natalBy = byName(natal); const transitBy = byName(transit); return aspects.map((a) => { const t = transitBy.get(capitalize(a.planet1)); const n = natalBy.get(capitalize(a.planet2)); if ( !t || !n || !Number.isFinite(t.longitude) || !Number.isFinite(n.longitude) ) return nothing; const from = polarToCartesian( CENTER, CENTER, HUB_R - 2, this.toAngle(t.longitude), ); const to = polarToCartesian( CENTER, CENTER, HUB_R - 2, this.toAngle(n.longitude), ); const name = normalizeAspect(a); const orb = formatNumber(this.effectiveLang(), a.orb, 1); return svg`${this.t('Transiting')} ${display(a, 'planet1')} ${display(a, 'type', name)} ${this.t('Natal')} ${display(a, 'planet2')}${orb ? ` (${this.t('Orb')} ${orb}°)` : ''}`; }); } /** * ASC and DSC ticks, drawn ONLY when an ascendant is available, from the prop * or from the response. Nothing is derived: the descendant is the exact * opposite point of that value. * * Gated on the ascendant alone, never on the cusps. Under whole-sign the first * cusp is 0 degrees of the rising sign rather than the Ascendant, so labelling * it ASC would be wrong on exactly the charts a traditional reader casts. */ private renderAxis() { const given = this.effectiveAscendant; if (given === undefined) return nothing; const asc = normalizeLongitude(given); return [asc, oppositePoint(asc)].map((lon, i) => { const angle = this.toAngle(lon); const inner = polarToCartesian(CENTER, CENTER, OUTER_R, angle); const tick = polarToCartesian(CENTER, CENTER, AXIS_TICK_R, angle); const label = polarToCartesian(CENTER, CENTER, AXIS_LABEL_R, angle); return svg` ${i === 0 ? this.t('ASC') : this.t('DSC')} `; }); } /** * The key to the wheel, and the one place the orientation is stated in words. * A reader who cannot tell whether the divisions are signs or houses cannot * reconcile the chart against another calculator, so the caption is * provenance rather than decoration. * * `houseSystem` labels the house NUMBERS in the table, which is the only thing * the response can vouch for, and the cusp chip separately says where the * drawn sectors came from. Two statements, each true on its own, rather than * one chip implying the ring and the numbers share a source. */ private renderLegend( natal: Body[], transit: Body[], aspects: TransitAspect[], houseSystem: string | undefined, ) { return html`
${this.t('{{count}} natal bodies', { count: natal.length })} ${this.t('{{count}} transiting bodies', { count: transit.length })} ${ aspects.length > 0 ? html`${this.t('Harmonious aspects')} ${this.t('Challenging aspects')}` : nothing } ${houseSystem ? html`${this.t('{{system}} houses', { system: houseSystem })}` : nothing} ${this.orientationCaption} ${this.cuspCaption}
`; } /** Transit weather: the counts and the tightest contact. All of it is data, so all of it survives hide-readings. */ private renderSummary(s: TransitSummary | undefined) { if (!s) return nothing; const byType = Object.entries(s.byType ?? {}).sort((a, b) => b[1] - a[1]); 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( // English in every language, and nothing to do about it here: `byType` // is an object KEYED by the canonical aspect name, so the response // carries no localized partner for these the way `aspects[]` does. ([type, count]) => html`${formatAspectName({ type })}: ${count}`, )}
${s.strongest ? this.renderStrongest(s.strongest) : nothing}
`; } /** The tightest transit by orb: the one contact most likely to be felt, lifted out so it is not buried among seventy others. */ private renderStrongest(s: NonNullable) { const transiting = display(s, 'planet1'); const natal = display(s, 'planet2'); return html`
${this.t('Strongest')} ${this.t('Transiting')} ${transiting} ${display(s, 'type', formatAspectName(s))} ${this.t('Natal')} ${natal} ${s.isApplying ? this.t('Applying') : this.t('Separating')} · ${this.t('Orb')} ${formatNumber(this.effectiveLang(), s.orb, 2)}° · ${this.t('Strength')} ${formatNumber(this.effectiveLang(), s.strength, 0)}
`; } /** * Both rings as exact positions, so every degree the wheel abbreviates to a * glyph stays readable and nothing the response carries is dropped. * * @remarks * The House columns are where the response's house numbers land, because the * wheel has no sectors to put them in unless a cusp set is available. Both * columns are read against the NATAL cusps: the transiting one is the natal * house that body is currently passing through, which is the whole point of a * transit report and is why the two are not the same fact under one heading. * `showHouses` comes from the payload rather than the type, so a response * without them loses the columns instead of printing a column of blanks. */ private renderPositions(natal: Body[], transit: Body[], showHouses: boolean) { if (natal.length === 0 && transit.length === 0) return nothing; const natalBy = byName(natal); const transitBy = byName(transit); const rows = natal.length > 0 ? natal : transit; const cell = (p: Body | undefined) => p ? html`${signGlyph(p.sign) ?? ''} ${formatWheelDegree(p.longitude)} ${display(p, 'sign')}${p.isRetrograde ? html`℞` : nothing}` : nothing; const houseCell = (p: Body | undefined) => showHouses ? html`${p && Number.isFinite(p.house) ? p.house : nothing}` : nothing; // tabindex + role: the House columns pushed this table past the card, so it // genuinely scrolls now, and a scrollable region with no focusable content // of its own is unreachable by keyboard. Same pattern as `hd-connection`, // except the name is borrowed from the caption rather than written again: // one translated sentence, and it cannot drift from what the table holds. return html`
${showHouses ? html`` : nothing} ${showHouses ? html`` : nothing} ${rows.map((p) => { const key = capitalize(p.name); const body = display(p, 'name'); return html` ${houseCell(natalBy.get(key))} ${houseCell(transitBy.get(key))} `; })}
${this.t('Every body with its natal position and its position on the transit date, each as a zodiac sign and a degree.')} ${showHouses ? this.t('Both house numbers are read against the natal house cusps.') : nothing}
${this.t('Body')} ${this.t('Natal')}${this.t('Natal house')}${this.t('Transiting')}${this.t('Transiting house')}
${body} ${cell(natalBy.get(key))}${cell(transitBy.get(key))}
`; } private renderPositionDetails(data: CalculateTransitAspectsResponse) { return html`
${this.t('Chart details')}

${this.t('Natal')}

${this.t('Transiting')}

`; } private renderAspectDetails(aspects: TransitAspect[]) { if (aspects.length === 0) return nothing; return html`
${this.t('Aspects ({{count}})', { count: aspects.length })}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-transit-wheel': RoxyTransitWheel; } }