import { css, html, nothing, svg } from 'lit'; import { customElement } from 'lit/decorators.js'; import type { LocalSpaceResponse } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { planetColor } from '../utils/planet-color.js'; type Body = LocalSpaceResponse['bodies'][number]; const SIZE = 320; const CENTER = SIZE / 2; const RIM = 128; const SPOKE = 118; const GLYPH_R = 140; const TICK_LABEL_R = 150; // Compass azimuth (0 = north, clockwise) to a screen point. North is up, east // is right, matching how the local space line is read off a real compass. function azimuthPoint(az: number, r: number): { x: number; y: number } { const rad = (az * Math.PI) / 180; return { x: CENTER + r * Math.sin(rad), y: CENTER - r * Math.cos(rad) }; } const PRINCIPAL = [ { az: 0, label: 'N' }, { az: 45, label: 'NE' }, { az: 90, label: 'E' }, { az: 135, label: 'SE' }, { az: 180, label: 'S' }, { az: 225, label: 'SW' }, { az: 270, label: 'W' }, { az: 315, label: 'NW' }, ]; /** * Local space compass. Plots each body from a /astrology/local-space response as * a directional line radiating from the birthplace at its azimuth (0 = north, * clockwise), with a 16-point ring. Bodies below the horizon are dimmed. Color * is per body and theme-token driven. */ @customElement('roxy-local-space-compass') export class RoxyLocalSpaceCompass extends RoxyDataElement { static styles = [ baseStyles, 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%; max-width: var(--roxy-chart-max-width, 480px); aspect-ratio: 1 / 1; height: auto; margin: 0 auto; } .dial { fill: none; stroke: var(--roxy-border, #e4e4e7); } .dial-fill { fill: color-mix(in srgb, var(--roxy-border, #e4e4e7) 10%, transparent); stroke: var(--roxy-border, #e4e4e7); stroke-width: 1; } .cardinal-axis { stroke: var(--roxy-border, #e4e4e7); stroke-width: 0.5; } .tick { stroke: var(--roxy-secondary, #475569); stroke-width: 0.6; opacity: 0.5; } .compass-label { fill: var(--roxy-secondary, #475569); font-size: 9px; font-weight: 600; font-family: var(--roxy-font-sans); } .compass-label.cardinal { fill: var(--roxy-fg, #0a0a0a); } .center-dot { fill: var(--roxy-fg, #0a0a0a); } .spoke { stroke-width: 1.4; } .spoke.below { stroke-dasharray: 3 3; opacity: 0.4; } .body-glyph { font-size: 11px; font-weight: 600; font-family: var(--roxy-font-sans); } .body-glyph.below { opacity: 0.45; } .list { width: 100%; border-collapse: collapse; font-size: var(--roxy-text-sm, 0.875rem); } .list th, .list td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--roxy-border, #e4e4e7); } .list th { color: var(--roxy-muted, #71717a); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; font-size: var(--roxy-text-xs, 0.75rem); } .list td.num { text-align: right; font-variant-numeric: tabular-nums; } .body-cell { display: inline-flex; align-items: center; gap: 0.4rem; } .body-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .horizon-pill { padding: 1px 7px; border-radius: var(--roxy-radius-full, 9999px); font-size: var(--roxy-text-xs, 0.75rem); } .horizon-pill.up { background: color-mix(in srgb, var(--roxy-success, #16a34a) 16%, transparent); color: var(--roxy-success-fg, #166534); } .horizon-pill.down { background: color-mix(in srgb, var(--roxy-border, #e4e4e7) 55%, transparent); color: var(--roxy-fg, #0a0a0a); } .summary { color: var(--roxy-fg, #0a0a0a); font-size: var(--roxy-text-sm, 0.875rem); margin: 0; } `, ]; protected renderEmpty() { return html`
No local space data
`; } protected renderData(data: LocalSpaceResponse) { const bodies = data.bodies ?? []; const bd = data.birthDetails; return html`

Local space

${ bd ? html`
${[bd.date, bd.time].filter(Boolean).join(' · ')}
` : nothing }
${this.renderDial(bodies)} ${data.summary ? html`

${data.summary}

` : nothing} ${this.renderList(bodies)}
`; } private renderDial(bodies: Body[]) { return html` Local space compass A compass centered on the birthplace. Each body is a line pointing to its azimuth, clockwise from north. Bodies below the horizon are dimmed. ${this.renderCompassRing()} ${this.renderSpokes(bodies)} `; } private renderCompassRing() { const ticks = []; // 16-point ring: a tick every 22.5 degrees. for (let az = 0; az < 360; az += 22.5) { const outer = azimuthPoint(az, RIM); const inner = azimuthPoint(az, RIM - (az % 45 === 0 ? 8 : 4)); ticks.push( svg``, ); } // Cardinal cross. const ns1 = azimuthPoint(0, RIM); const ns2 = azimuthPoint(180, RIM); const ew1 = azimuthPoint(90, RIM); const ew2 = azimuthPoint(270, RIM); const labels = PRINCIPAL.map(({ az, label }) => { const pos = azimuthPoint(az, TICK_LABEL_R); const cardinal = az % 90 === 0; return svg`${label}`; }); return svg` ${ticks}${labels}`; } private renderSpokes(bodies: Body[]) { return bodies.map((b, i) => { const color = planetColor(b.planet, i); const below = b.aboveHorizon === false; const end = azimuthPoint(b.azimuth, SPOKE); const glyphPos = azimuthPoint(b.azimuth, GLYPH_R); const glyph = b.symbol || b.planet.slice(0, 2); const altLabel = `${b.altitude > 0 ? '+' : ''}${Math.round(b.altitude)}°`; return svg` ${b.planet} ${b.compassDirection} ${Math.round(b.azimuth)}° altitude ${altLabel} ${glyph} `; }); } private renderList(bodies: Body[]) { if (bodies.length === 0) return nothing; return html` ${bodies.map((b, i) => { const color = planetColor(b.planet, i); const below = b.aboveHorizon === false; return html``; })}
Body Direction Azimuth Altitude Horizon
${b.symbol ? html`${b.symbol} ` : nothing}${b.planet} ${b.compassDirection} ${Math.round(b.azimuth)}° ${b.altitude > 0 ? '+' : ''}${Math.round(b.altitude)}° ${below ? 'Below' : 'Above'}
`; } } declare global { interface HTMLElementTagNameMap { 'roxy-local-space-compass': RoxyLocalSpaceCompass; } }