import { css, html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { kuaCardEndpoints } from '../generated/endpoint-defaults.js'; import type { CalculateKuaNumberResponse, GenerateEightMansionsResponse, } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { customElement } from '../utils/define.js'; import { formatDate, formatDateGrain } from '../utils/format.js'; import { frameCaptionStyles, renderConventionsCaption, } from '../utils/frame.js'; import { labelRowStyles } from '../utils/label-row.js'; import { display, displayField, displayOption } from '../utils/localized.js'; import { GRID_ORDER } from '../utils/nine-palaces.js'; import { plateHeadingStyles } from '../utils/plate-heading.js'; import { capitalize } from '../utils/string.js'; type KuaData = CalculateKuaNumberResponse | GenerateEightMansionsResponse; /** * Kua number and the Eight Mansions. Pass `data` from POST /feng-shui/kua, or set * `mode="mansions"` and pass POST /feng-shui/eight-mansions. * * @remarks * The eight sectors are drawn on the same nine-palace grid as the flying-star plate, south at the * top, with the person's own Kua number and trigram in the centre where the plate puts the period * star. That is not decoration: a reader who has both cards open is looking at one building, and * two grids that disagree about which way is up cannot be laid over each other. The geometry is * shared rather than copied ({@link GRID_ORDER}), so the two cannot drift apart. * * **Four sectors are favourable and four are not, always, and the card shows all eight.** Ba Zhai * divides the compass in half for every Kua number, so a card that showed only the good directions * would be advice rather than the map, and a reader could not tell a missing sector from an * unfavourable one. Rank is printed beside each, because the four favourable sectors are ordered * and the first of them is the one a household gets right first. * * `hide-readings` keeps the whole map: every sector, its star, its nature, its rank and its domain, * the Kua number, the group, the trigram and the boundary date. The per-sector reading goes. * * **Every heading and fact label is now catalogued, and the compass grid reads the same published * option a Vastu facing field already publishes** (`displayOption(lang, 'facing', ...)`), so this * grid cannot disagree with that form field about what a compass direction is called. `sector.star` * has a localized display name (`starNameLocalized`); the trigram's own English name and the group * (east/west) it names carry no such sibling and stay exactly as the response sends them. */ @customElement('roxy-kua-card') export class RoxyKuaCard extends RoxyDataElement { static endpoints = kuaCardEndpoints; static styles = [ baseStyles, frameCaptionStyles, plateHeadingStyles, labelRowStyles, css` .card { 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); } .head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.35rem var(--roxy-space-md, 1rem); } .title { margin: 0; font-size: var(--roxy-text-lg, 1.125rem); font-weight: var(--roxy-weight-bold, 600); letter-spacing: var(--roxy-tracking-tight); } .group { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-sm, 0.875rem); } .facts { display: flex; flex-wrap: wrap; gap: 0.35rem var(--roxy-space-md, 1rem); font-size: var(--roxy-text-sm, 0.875rem); color: var(--roxy-secondary, #475569); } .facts .lbl { 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); margin-right: 0.35rem; } .facts b { color: var(--roxy-fg, #0a0a0a); font-weight: var(--roxy-weight-bold, 600); } /* No width cap: the plate is the figure this card is read off, so it fills * the host and the consumer sizes the host. The plate part is the name the * layout gate measures against the card content box. */ .plate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; background: var(--roxy-border, #e4e4e7); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); overflow: hidden; } .sector { background: var(--roxy-surface, #fff); padding: var(--roxy-space-sm, 0.5rem); display: grid; gap: 0.1rem; align-content: start; min-height: 5.5rem; text-align: center; } /* Muted ink reads at roughly 3:1 over a status tint, so a tinted cell takes * the darker neutral. The same trap as painting a tint and drawing its ink * from the base token rather than its -fg partner. */ .sector-good .sector-name, .sector-bad .sector-name, .sector-self .sector-name, .sector-good .rank, .sector-bad .rank { color: var(--roxy-secondary, #475569); } .sector-good .star-hanzi, .sector-bad .star-hanzi { color: var(--roxy-fg, #0a0a0a); } .sector-good { background: color-mix(in srgb, var(--roxy-success, #16a34a) 10%, var(--roxy-surface, #fff)); } .sector-bad { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 10%, var(--roxy-surface, #fff)); } .sector-self { background: color-mix(in srgb, var(--roxy-accent, #f59e0b) 10%, var(--roxy-surface, #fff)); } .star-name { font-size: var(--roxy-text-sm, 0.875rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-fg, #0a0a0a); } .star-hanzi { font-size: var(--roxy-text-base, 1rem); color: var(--roxy-accent-ink, #b45309); } .domain { font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-secondary, #475569); } .rank { font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); font-variant-numeric: tabular-nums; } .centre { display: grid; gap: 0.1rem; align-content: center; } .kua-number { font-size: 2rem; line-height: 1; font-weight: var(--roxy-weight-bold, 600); font-variant-numeric: tabular-nums; } .trigram-symbol { font-size: 1.5rem; line-height: 1.1; color: var(--roxy-accent-ink, #b45309); } /* Below 30rem a 3x3 cell cannot hold a sector name, a star, a domain phrase * and a rank at once in every shipped language: German and Hindi both write * more of these four than English does. The eight sectors read as a * full-width list instead, in the same plate order, with the same * favourable/unfavourable tint (that rule is unconditional above and needs * no repeating here). The centre is not a sector and drops out rather than * becoming a ninth row: the Kua number and the trigram it names are already * the card's own heading and its first fact, so nothing is lost by it. * The flying-star plate keeps its 3x3 at this width on purpose, because its * cells hold three short numbers rather than this much prose. */ @container (max-width: 30rem) { .plate { display: block; background: var(--roxy-surface, #fff); } .sector.centre { display: none; } .sector { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'name rank' 'star hanzi' 'domain domain'; column-gap: var(--roxy-space-sm, 0.5rem); row-gap: 0.15rem; align-content: start; min-height: auto; text-align: left; border-bottom: 1px solid var(--roxy-border, #e4e4e7); } .sector:last-child { border-bottom: 0; } .sector-name { grid-area: name; align-self: center; } .rank { grid-area: rank; align-self: center; text-align: right; white-space: nowrap; } .star-name { grid-area: star; } .star-hanzi { grid-area: hanzi; text-align: right; } .domain { grid-area: domain; } } .legend { display: flex; flex-wrap: wrap; gap: 0.35rem var(--roxy-space-md, 1rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); } .legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; } .swatch-good { background: var(--roxy-success, #16a34a); } .swatch-bad { background: var(--roxy-danger, #dc2626); } .block-title { margin: 0 0 var(--roxy-space-sm, 0.5rem); font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); font-weight: var(--roxy-weight-bold, 600); text-transform: uppercase; letter-spacing: 0.06em; } .rows { margin: 0; padding: 0; list-style: none; display: grid; } .row { border-top: 1px solid var(--roxy-border, #e4e4e7); padding-block: var(--roxy-space-sm, 0.5rem); gap: 0.15rem var(--roxy-space-md, 1rem); align-items: baseline; font-size: var(--roxy-text-sm, 0.875rem); } .row:first-child { border-top: 0; padding-top: 0; } .row-name { font-weight: var(--roxy-weight-bold, 600); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.06em; color: var(--roxy-muted, #71717a); } .row-body p { margin: 0.15rem 0 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-secondary, #475569); } `, ]; /** Which read the response is: the Kua number alone, or the full Eight Mansions map. */ @property({ type: String, reflect: true }) mode: 'kua' | 'mansions' = 'kua'; protected renderData(d: KuaData) { const locale = this.effectiveLang(); const trigram = d.trigram; return html`

${this.t('Kua')} ${d.kua ?? ''}

${ d.group ? html`${this.t('{{direction}} group', { direction: displayOption(locale, 'facing', capitalize(d.group)), })}` : nothing }
${ trigram ? html`${this.t('Trigram')} ${trigram.chinese ?? ''} ${trigram.symbol ?? ''} ${trigram.english ?? ''} ${display(trigram, 'element')}` : nothing } ${ trigram?.familyMember ? html`${this.t('Role')}${trigram.familyMember}` : nothing } ${'solarYear' in d && typeof d.solarYear === 'number' ? html`${this.t('Year')}${formatDateGrain(locale, String(d.solarYear), 'year')}` : nothing} ${'gender' in d && d.gender ? html`${displayField(locale, 'gender')}${displayOption(locale, 'gender', d.gender)}` : nothing} ${ // The formula reads the Chinese year, which turns in early February, so // the boundary is what a reader born in January has to check. 'boundaryDate' in d && d.boundaryDate ? html`${this.t('Year boundary')} ${formatDate(locale, d.boundaryDate)}` : nothing } ${ 'reassigned' in d && d.reassigned ? html`${this.t('Reassigned from {{value}}', { value: String('rawKua' in d ? (d.rawKua ?? '') : ''), })}` : nothing } ${this.renderBestWorst(d)}
${GRID_ORDER.map((name) => this.renderSector(d, name))}
${this.t('Favourable')} ${this.t('Unfavourable')}
${this.renderSectorRows(d)} ${ 'conventions' in d ? renderConventionsCaption( d.conventions, this.translator, this.effectiveLang(), ) : nothing }
`; } /** The single best and worst sector, which the mansions response names outright. */ private renderBestWorst(d: KuaData) { // The attribute decides whether the fuller read is drawn and the shape check // narrows the type under it, so a page that asked for the Kua number alone // never grows a verdict its endpoint did not return. if (this.mode !== 'mansions' || !('bestSector' in d)) return nothing; const locale = this.effectiveLang(); return html`${ d.bestSector ? html`${this.t('Best')}${displayOption(locale, 'facing', d.bestSector)}` : nothing } ${ d.worstSector ? html`${this.t('Worst')}${displayOption(locale, 'facing', d.worstSector)}` : nothing }`; } /** * The compass heading for a grid position, read off the same published `facing` option a Vastu * form field already publishes so this grid cannot disagree with that field about what a * direction is called. The centre is not a compass sector and reads the shared `Center` word * instead. */ private sectorHeading(locale: string | undefined, name: string): string { return name === 'Center' ? this.t('Center') : displayOption(locale, 'facing', name); } /** * One cell of the map, found by sector name so the drawing order is this component's. * * @remarks * The centre is not a sector. Ba Zhai divides the eight compass directions and says nothing * about the middle of a building, so that cell carries the person the map is FOR: their Kua * number and the trigram it resolves to. Filling it with a ninth direction would invent a * reading the system does not have. */ private renderSector(d: KuaData, name: string) { const locale = this.effectiveLang(); const heading = this.sectorHeading(locale, name); if (name === 'Center') { return html`
${d.kua ?? ''} ${d.trigram?.symbol ?? ''} ${d.trigram?.english ?? ''}
`; } const sector = (d.sectors ?? []).find( (s: { direction?: string }) => s.direction === name, ); if (!sector) { return html`
${heading}
`; } const good = sector.nature === 'auspicious'; return html`
${heading} ${display(sector, 'starName')} ${ 'chinese' in sector && sector.chinese ? html`${sector.chinese}` : nothing } ${sector.domain ?? ''} ${ typeof sector.rank === 'number' ? html`${good ? this.t('Rank') : this.t('Severity')} ${sector.rank}` : nothing }
`; } /** Every sector again as a list, which is where the per-sector reading lives. */ private renderSectorRows(d: KuaData) { const rows = d.sectors ?? []; if (rows.length === 0) return nothing; const locale = this.effectiveLang(); const bodies = rows.map((s) => { const reading = this.mode === 'mansions' && 'reading' in s ? s.reading : undefined; if (!reading || this.hideReadings) return nothing; return html`
  • ${this.sectorHeading(locale, s.direction ?? '')}
    ${display(s, 'starName')}

    ${reading}

  • `; }); if (bodies.every((b) => b === nothing)) return nothing; return html`

    ${this.t('Sectors')}

    `; } } declare global { interface HTMLElementTagNameMap { 'roxy-kua-card': RoxyKuaCard; } }