import { css, html, nothing, svg } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { AstrocartographyResponse } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { chevron, disclosureStyles } from '../utils/disclosure.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 (plate carree) projection in a 360x180 unit canvas: one unit // per degree. The SVG scales to the container width; the 2:1 aspect ratio holds. const W = 360; const H = 180; const lonToX = (lon: number): number => lon + 180; const latToY = (lat: number): number => 90 - lat; // 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(' ')); } const ANGLE_LABEL: Record = { mc: 'MC', ic: 'IC', ascendant: 'AC', descendant: 'DC', }; /** * 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 styles = [ baseStyles, disclosureStyles, 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; 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; } .interpretations h3 { font-size: var(--roxy-text-sm, 0.875rem); font-weight: 600; color: var(--roxy-muted, #71717a); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 var(--roxy-space-sm, 0.5rem); } .interp-card { 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); } .interp-card summary { cursor: pointer; font-weight: 500; color: var(--roxy-fg, #0a0a0a); display: flex; align-items: center; justify-content: space-between; gap: var(--roxy-space-md, 1rem); } .interp-head { display: inline-flex; align-items: center; gap: 0.5rem; } .interp-dot { width: 10px; height: 10px; border-radius: 50%; } .interp-body { margin-top: var(--roxy-space-sm, 0.5rem); display: grid; gap: var(--roxy-space-xs, 0.25rem); } .interp-line { font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-fg, #0a0a0a); } .interp-line .code { font-weight: 600; color: var(--roxy-accent-ink, #b45309); margin-right: 0.4rem; } `, ]; protected renderEmpty() { return html`
No astrocartography data
`; } protected renderData(data: AstrocartographyResponse) { const lines = data.lines ?? []; const bd = data.birthDetails; return html`

Astrocartography

${ bd ? html`
${[bd.date, bd.time].filter(Boolean).join(' · ')} · ${formatLat(Math.round(bd.latitude))} ${formatLon(Math.round(bd.longitude))}
` : nothing }
${this.renderMap(lines, bd)} ${this.renderLegend(lines)} ${data.summary ? html`

${data.summary}

` : nothing} ${this.renderInterpretations(lines)}
`; } private renderMap( lines: LineSet[], bd: AstrocartographyResponse['birthDetails'], ) { return html` Astrocartography world map Equirectangular world map. Each body has a Midheaven and Imum Coeli meridian and a curved Ascendant and Descendant line, colored per body. ${this.renderGraticule()} ${lines.map((l, i) => this.renderBodyLines(l, i))} ${ bd ? svg`Birthplace` : 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}`; } private renderBodyLines(line: LineSet, index: number) { const color = planetColor(line.planet, index); const glyph = line.symbol || line.planet.slice(0, 2); const items = [ this.renderMeridian( line.mc.longitude, color, glyph, line.planet, 'mc', false, ), this.renderMeridian( line.ic.longitude, color, glyph, line.planet, 'ic', true, ), this.renderCurve( line.ascendant.points, color, glyph, line.planet, 'ascendant', false, ), this.renderCurve( line.descendant.points, color, glyph, line.planet, 'descendant', true, ), ]; return svg`${items}`; } private renderMeridian( lon: 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. const labelY = angle === 'ic' ? H - 7 : 9; return svg` ${planet} ${ANGLE_LABEL[angle]} line ${glyph} `; } private renderCurve( points: GeoPoint[], color: string, glyph: string, planet: string, angle: string, dashed: boolean, ) { const segments = toSegments(points ?? []); if (segments.length === 0) return nothing; // Label at the sample nearest the equator, the most visible band. const anchor = (points ?? []).reduce( (best, p) => (Math.abs(p.latitude) < Math.abs(best.latitude) ? p : best), points[0] ?? { latitude: 0, longitude: 0 }, ); return svg` ${segments.map( (pts) => svg`${planet} ${ANGLE_LABEL[angle]} line`, )} ${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}${l.planet} `; })} Solid lines are the Ascendant and Midheaven, dashed are the Descendant and IC.
`; } private renderInterpretations(lines: LineSet[]) { if (lines.length === 0) return nothing; return html`

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}${l.planet} ${chevron()}
${rows .filter(([, text]) => text) .map( ([code, text]) => html`

${code}${text}

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