import { css, html, nothing, svg } from 'lit'; import { astrocartographyMapEndpoints } from '../generated/endpoint-defaults.js'; import type { ChromeString } from '../i18n/chrome-strings.js'; import { planetGlyph } from '../tokens/index.js'; import type { AstrocartographyResponse } from '../types/index.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 { fanOut } from '../utils/degree.js'; import { chevron, disclosureStyles } from '../utils/disclosure.js'; import { formatDateTime } from '../utils/format.js'; import { interpAccordionStyles } from '../utils/interp-accordion.js'; import { display } from '../utils/localized.js'; import { planetColor } from '../utils/planet-color.js'; import { WORLD_LAND_PATH } from '../utils/world-map.js'; type LineSet = AstrocartographyResponse['lines'][number]; type GeoPoint = LineSet['ascendant']['points'][number]; // Equirectangular map coordinates use one user unit per degree. const W = 360; const H = 180; const lonToX = (lon: number): number => lon + 180; const latToY = (lat: number): number => 90 - lat; /** Degrees of longitude two edge glyphs need between them: the glyph is 8 units on a map where one unit is one degree, plus a hair of air. */ const GLYPH_SEPARATION = 9; // Reference parallels (degrees). Tropics and polar circles use the current mean // obliquity; drawn as dashed guides so the curved rising/setting lines read // against real climate bands, not just a bare grid. const TROPIC = 23.44; const POLAR = 66.56; const formatLon = (lon: number): string => lon === 0 ? '0' : `${Math.abs(lon)}°${lon > 0 ? 'E' : 'W'}`; const formatLat = (lat: number): string => lat === 0 ? '0' : `${Math.abs(lat)}°${lat > 0 ? 'N' : 'S'}`; /** * Split a rising/setting line into screen polylines, breaking the path wherever * consecutive samples jump more than 180 degrees of longitude. Without the * split, a line that crosses the antimeridian draws a stray horizontal streak * straight across the whole map. */ function toSegments(points: GeoPoint[]): string[] { const segments: string[][] = []; let current: string[] = []; let prevLon: number | null = null; for (const p of points) { if (prevLon !== null && Math.abs(p.longitude - prevLon) > 180) { if (current.length) segments.push(current); current = []; } current.push(`${lonToX(p.longitude)},${latToY(p.latitude)}`); prevLon = p.longitude; } if (current.length) segments.push(current); return segments.filter((s) => s.length > 1).map((s) => s.join(' ')); } /** Response angle key to the English SOURCE its label is looked up by. The same four tokens the natal wheel draws, so one chart cannot call an axis `AC` while the other calls it `ASC`. */ const ANGLE_LABEL: Record = { mc: 'MC', ic: 'IC', ascendant: 'ASC', descendant: 'DSC', }; /** * Astrocartography (relocation) world map. Plots the four planetary lines for * every body from a /astrology/astrocartography response over a labeled * graticule: MC and IC as straight meridians, the Ascendant and Descendant as * latitude-sampled curves. Color is per body and theme-token driven; solid * lines are the Ascendant and Midheaven, dashed are the Descendant and IC. */ @customElement('roxy-astrocartography-map') export class RoxyAstrocartographyMap extends RoxyDataElement { static endpoints = astrocartographyMapEndpoints; private readonly chartViewport = new ChartViewport(this); static styles = [ baseStyles, chartViewportStyles, disclosureStyles, interpAccordionStyles, css` .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; /* Never an implicit auto column: it floors at min-content, so one long * unbreakable string widens the track past the padded card. */ grid-template-columns: minmax(0, 1fr); gap: var(--roxy-space-md, 1rem); } .title { 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%; height: auto; border-radius: var(--roxy-radius-sm, 4px); } .map-frame { fill: color-mix(in srgb, var(--roxy-border, #e4e4e7) 12%, transparent); stroke: var(--roxy-border, #e4e4e7); stroke-width: 0.8; } .land { fill: var(--roxy-secondary, #475569); opacity: 0.13; } .grat { stroke: var(--roxy-border, #e4e4e7); stroke-width: 0.4; fill: none; } .grat-axis { stroke: var(--roxy-muted, #71717a); stroke-width: 0.6; opacity: 0.6; } .grat-ref { stroke: var(--roxy-secondary, #475569); stroke-width: 0.4; stroke-dasharray: 2 2; opacity: 0.5; fill: none; } .axis-label { fill: var(--roxy-muted, #71717a); font-size: 5px; font-family: var(--roxy-font-sans); } .acg-line { fill: none; stroke-width: 1; opacity: 0.95; } .acg-line.dashed { stroke-dasharray: 4 2.5; } .acg-glyph { font-size: 8px; font-family: var(--roxy-font-sans); font-weight: 600; } .birthplace { fill: var(--roxy-fg, #0a0a0a); font-size: 9px; } .legend { display: flex; flex-wrap: wrap; gap: var(--roxy-space-xs, 0.25rem) var(--roxy-space-md, 1rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); } .legend-item { display: inline-flex; align-items: center; gap: 0.3rem; } .legend-swatch { width: 14px; height: 0; border-top-width: 2px; border-top-style: solid; } .legend-note { width: 100%; color: var(--roxy-muted, #71717a); } .summary { color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); margin: 0; } /* The shared lead is baseline-aligned for text; an empty swatch has no * baseline worth aligning to, so centre it against the planet name. */ .interp-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; align-self: center; } .interp-line { font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); margin: 0; } .interp-line .code { font-weight: 600; color: var(--roxy-accent-ink, #b45309); margin-right: 0.4rem; } `, ]; /** Fits the measured map ink without moving geographic coordinates. */ protected updated(): void { for (const map of this.renderRoot.querySelectorAll( 'svg[part="chart"]', )) { if (typeof map.getBBox !== 'function') continue; const { x, y, width, height } = map.getBBox(); map.setAttribute( 'viewBox', [x - 1, y - 1, width + 2, height + 2].join(' '), ); } } protected renderData(data: AstrocartographyResponse) { const lines = data.lines ?? []; const bd = data.birthDetails; return html`

${this.t('Astrocartography')}

${ bd ? html`
${formatDateTime(this.effectiveLang(), bd.date, bd.time)} · ${formatLat(Math.round(bd.latitude))} ${formatLon(Math.round(bd.longitude))}
` : nothing }
${this.chartViewport.renderChartViewport(this.renderMap(lines, bd), this.t('Astrocartography'), this.translator)} ${this.renderLegend(lines)} ${ // The map and its key are the deliverable here; the summary is prose // about what the map shows. data.summary && !this.hideReadings ? html`

${data.summary}

` : nothing } ${this.renderInterpretations(lines)}
`; } private renderMap( lines: LineSet[], bd: AstrocartographyResponse['birthDetails'], ) { return html` ${this.t('Astrocartography world map')} ${this.t( 'Equirectangular world map. Each body has a Midheaven and Imum Coeli meridian and a curved Ascendant and Descendant line, colored per body.', )} ${this.renderGraticule()} ${this.renderBodyLines(lines)} ${ bd ? svg`${this.t('Birth location')}★` : nothing } `; } private renderGraticule() { const meridians = []; for (let lon = -150; lon <= 150; lon += 30) { const x = lonToX(lon); const axis = lon === 0; meridians.push( svg``, ); meridians.push( svg`${formatLon(lon)}`, ); } const parallels = []; for (let lat = -60; lat <= 60; lat += 30) { const y = latToY(lat); const axis = lat === 0; parallels.push( svg``, ); parallels.push( svg`${formatLat(lat)}`, ); } // Tropics and polar circles as dashed climate-band references. const refs = [TROPIC, -TROPIC, POLAR, -POLAR].map( (lat) => svg``, ); return svg`${meridians}${parallels}${refs}`; } /** * Every body's four lines, with the glyph that names each line fanned along * its edge so two bodies within a few degrees of longitude never print one * glyph over the other. Longitude wraps like a wheel, so the same fan the * wheels use spaces the glyphs; the LINE stays at its true longitude and the * colour ties a displaced glyph back to it. */ private renderBodyLines(lines: LineSet[]) { const bodies = lines.map((line, index) => { const name = display(line, 'planet'); return { line, name, color: planetColor(line.planet, index), // Response symbol first, then the shared table, then the full name. Never a // truncation: `North Node.slice(0, 2)` labelled a whole map line "No". glyph: line.symbol || planetGlyph(line.planet) || name, }; }); type Body = (typeof bodies)[number]; const nearEquator = (points: GeoPoint[]): GeoPoint => points.reduce( (best, p) => Math.abs(p.latitude) < Math.abs(best.latitude) ? p : best, points[0] ?? { latitude: 0, longitude: 0 }, ); // The label longitude for each line of each body, fanned per edge. const fanned = (longitudeOf: (b: Body) => number) => new Map( fanOut(bodies, (b) => longitudeOf(b) + 180, GLYPH_SEPARATION).map( ({ item, displayLongitude }) => [item, displayLongitude - 180], ), ); const mcAt = fanned((b) => b.line.mc.longitude); const icAt = fanned((b) => b.line.ic.longitude); const ascAt = fanned( (b) => nearEquator(b.line.ascendant.points ?? []).longitude, ); const dscAt = fanned( (b) => nearEquator(b.line.descendant.points ?? []).longitude, ); return bodies.map((b) => { const { line, name, color, glyph } = b; const at = (m: Map) => m.get(b) ?? 0; return svg`${[ this.renderMeridian( line.mc.longitude, at(mcAt), color, glyph, name, 'mc', false, ), this.renderMeridian( line.ic.longitude, at(icAt), color, glyph, name, 'ic', true, ), this.renderCurve( line.ascendant.points, { longitude: at(ascAt), latitude: nearEquator(line.ascendant.points ?? []).latitude, }, color, glyph, name, 'ascendant', false, ), this.renderCurve( line.descendant.points, { longitude: at(dscAt), latitude: nearEquator(line.descendant.points ?? []).latitude, }, color, glyph, name, 'descendant', true, ), ]}`; }); } private renderMeridian( lon: number, labelLon: number, color: string, glyph: string, planet: string, angle: string, dashed: boolean, ) { const x = lonToX(lon); // MC label rides the top edge, IC the bottom, so the two meridians of one // body never stack their glyphs at the same point; the IC row sits above // the longitude labels along the bottom edge. const labelY = angle === 'ic' ? H - 16 : 9; return svg` ${this.t('{{planet}} {{angle}} line', { planet, angle: this.t(ANGLE_LABEL[angle] ?? 'MC') })} ${glyph} `; } private renderCurve( points: GeoPoint[], label: GeoPoint, color: string, glyph: string, planet: string, angle: string, dashed: boolean, ) { const segments = toSegments(points ?? []); if (segments.length === 0) return nothing; // Labelled at the sample nearest the equator, the most visible band. return svg` ${segments.map( (pts) => svg`${this.t('{{planet}} {{angle}} line', { planet, angle: this.t(ANGLE_LABEL[angle] ?? 'MC') })}`, )} ${glyph} `; } private renderLegend(lines: LineSet[]) { if (lines.length === 0) return nothing; return html`
${lines.map((l, i) => { const color = planetColor(l.planet, i); return html` ${l.symbol ? html`${l.symbol} ` : nothing}${display(l, 'planet')} `; })} ${this.t('Solid lines are the Ascendant and Midheaven, dashed are the Descendant and IC.')}
`; } /** * The written read of each planetary line. Prose end to end, and the lines it * describes are already drawn and keyed on the map above, so `hide-readings` * takes the section whole. */ private renderInterpretations(lines: LineSet[]) { if (lines.length === 0 || this.hideReadings) return nothing; return html`

${this.t('Planetary lines')}

${lines.map((l, i) => { const color = planetColor(l.planet, i); const rows: Array<[string, string]> = [ ['MC', l.mc.interpretation], ['IC', l.ic.interpretation], ['AC', l.ascendant.interpretation], ['DC', l.descendant.interpretation], ]; return html`
${l.symbol ? html`${l.symbol} ` : nothing}${display(l, 'planet')} ${chevron()}
${rows .filter(([, text]) => text) .map( ([code, text]) => html`

${code}${text}

`, )}
`; })}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-astrocartography-map': RoxyAstrocartographyMap; } }