import { css, html, nothing } from 'lit'; import { doshaConstitutionEndpoints } from '../generated/endpoint-defaults.js'; import type { ChromeString } from '../i18n/chrome-strings.js'; import type { CalculateAyurvedicConstitutionResponse } from '../types/index.js'; import { RoxyDataElement } from '../utils/base-element.js'; import { baseStyles } from '../utils/base-styles.js'; import { citationLine } from '../utils/citation.js'; import { customElement } from '../utils/define.js'; import { chevron, disclosureStyles } from '../utils/disclosure.js'; import { formatInteger, formatList, formatNumber, formatPercent, } from '../utils/format.js'; import { frameCaptionStyles, renderFrameCaption } from '../utils/frame.js'; import { labelRowStyles } from '../utils/label-row.js'; import { capitalize, humanize } from '../utils/string.js'; type Constitution = CalculateAyurvedicConstitutionResponse; type Factor = NonNullable[number]; type Citation = Factor['source']; /** The three humours in the order the composite reports them, which is also the order the bar is read in. */ const HUMOURS = ['vata', 'pitta', 'kapha'] as const; /** * What each scored factor READ, as a heading. * * @remarks * The three ids are a closed set the spec states outright, and none of them carries a localized * partner, so these are words the component writes rather than words the response sent. Two of the * three already have a catalogue entry that another Vedic card uses, so a reader meets the same term * on both. Typed `ChromeString`, which is what keeps a dynamic lookup inside the catalogue. */ const FACTOR_LABEL: Record = { 'lagna-sign': 'Lagna', 'moon-sign': 'Moon sign', 'strongest-planet': 'Strongest graha', }; /** `Vata-pitta` from `vata-pitta`, keeping the hyphen the API uses for a dual type. */ function formatType(type: string): string { return type.split('-').map(capitalize).join('-'); } /** * What a factor read: one sign id, or the grahas that reached the strength cutoff. * * @remarks * Both come back canonical English with no localized partner on this response, so they print as the * API sent them with the case a reader expects. A sign name has a translated form on other * operations and deliberately none here; looking it up in another response to close that would be a * second translation of the same fact. * * The strongest-graha factor packs its grahas into ONE space separated string, so a bare print reads * as a typo (`Mercury Jupiter`). The list is joined the way the reader's language joins one, which is * a fact about the language rather than about the chart. */ function formatInput( locale: string | undefined, input: string | undefined, ): string { return formatList( locale, (input ?? '').split(' ').filter(Boolean).map(capitalize), ); } /** * Ayurvedic constitution from a birth chart. Pass `data` from POST /ayurveda/constitution. * * @remarks * **The three shares are one bar, and the bar is the card.** They sum to exactly 100 by largest * remainder, so a stacked bar is the honest drawing rather than three separate meters: the reading * is a split of one whole, and three bars invite a reader to compare each against a full width that * does not exist. Every segment carries its own percentage beside the humour, so the picture and the * numbers cannot disagree. * * **The humour identifiers are printed as the response sends them, in every language.** Vata, pitta * and kapha are Sanskrit identifiers the API states outright are never translated, so they are * vocabulary rather than chrome and nothing here catalogues them. * * **`composite.convention` is a version identifier and is not printed.** It names the release of the * blending rule so a caller comparing two readings months apart can tell whether the rule moved, and * the field-label payload publishes no reader-facing name for it, so a card printing it shows a * developer string with no label a visitor can act on. What the convention IS reaches the reader as * numbers instead: every factor prints the weight it was given, and the summary says the weighting is * ours rather than classical. * * **`meta.disclaimer` is rendered verbatim and `hide-readings` does not touch it.** It states the * scope of everything above it, which is a fact about the response rather than an interpretation of * a chart, and a page that drops the reading has more need of it rather than less. * * The factors are the part a practitioner checks: each names what it read, the humours it carries, * the weight it was given and the verse it rests on. The weighting is ours rather than classical, * which is why the weights are on the card rather than behind it. */ @customElement('roxy-dosha-constitution') export class RoxyDoshaConstitution extends RoxyDataElement { static endpoints = doshaConstitutionEndpoints; static styles = [ baseStyles, frameCaptionStyles, disclosureStyles, 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); } .type { font-size: var(--roxy-text-xl, 1.25rem); font-weight: var(--roxy-weight-bold, 600); color: var(--roxy-accent-ink, #b45309); } .bar { display: flex; width: 100%; min-width: 0; height: 1.5rem; border-radius: var(--roxy-radius-sm, 4px); overflow: hidden; border: 1px solid var(--roxy-border, #e4e4e7); } .seg { min-width: 0; } .seg-vata { background: color-mix(in srgb, var(--roxy-info, #0284c7) 45%, var(--roxy-surface, #fff)); } .seg-pitta { background: color-mix(in srgb, var(--roxy-danger, #dc2626) 45%, var(--roxy-surface, #fff)); } .seg-kapha { background: color-mix(in srgb, var(--roxy-success, #16a34a) 45%, var(--roxy-surface, #fff)); } .shares { 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); } .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; } .shares b { color: var(--roxy-fg, #0a0a0a); font-variant-numeric: tabular-nums; } .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); } .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); } .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); } .cite { margin: 0.15rem 0 0; font-size: var(--roxy-text-xs, 0.75rem); color: var(--roxy-muted, #71717a); line-height: var(--roxy-leading-normal, 1.5); } .cite p { margin: 0; } .cite .note summary { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; width: fit-content; color: var(--roxy-secondary, #475569); } .cite .note p { margin: 0.15rem 0 0; } .scroll { overflow-x: auto; min-width: 0; } table { border-collapse: collapse; width: 100%; font-size: var(--roxy-text-sm, 0.875rem); } th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--roxy-border, #e4e4e7); white-space: nowrap; } th { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); text-transform: uppercase; letter-spacing: 0.06em; } td.num { font-variant-numeric: tabular-nums; } tr.is-strong td { font-weight: var(--roxy-weight-bold, 600); } .summary { margin: 0; font-size: var(--roxy-text-sm, 0.875rem); line-height: var(--roxy-leading-relaxed, 1.65); } .disclaimer { margin: 0; padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); border-left: 3px solid var(--roxy-border, #e4e4e7); font-size: var(--roxy-text-xs, 0.75rem); line-height: var(--roxy-leading-normal, 1.5); color: var(--roxy-secondary, #475569); } `, ]; protected renderData(d: Constitution) { const locale = this.effectiveLang(); const c = d.composite; return html`

${this.t('Ayurvedic constitution')}

${c?.type ? html`${formatType(c.type)}` : nothing}
${this.renderBar(locale, d)}
${ c?.dominant ? html`${this.t('Dominant')}${capitalize(c.dominant)}` : nothing } ${ c?.secondary ? html`${this.t('Secondary')}${capitalize(c.secondary)}` : nothing } ${ d.lagnaSign ? html`${this.t('Lagna')}${capitalize(d.lagnaSign)}` : nothing } ${ d.moonSign ? html`${this.t('Moon sign')}${capitalize(d.moonSign)}` : nothing }
${this.renderFactors(locale, d.factors)} ${this.renderRanking(locale, d.strengthRanking)} ${this.renderPlanetDoshas(d.planetDoshas)} ${ d.summary && !this.hideReadings ? html`

${d.summary}

` : nothing } ${renderFrameCaption(locale, d.frame, this.translator)} ${ // The scope of everything above it, so it is never behind the readings // gate: a page showing only the chart needs it more, not less. d.meta?.disclaimer ? html`

${d.meta.disclaimer}

` : nothing }
`; } /** The composite as one stacked bar plus the three shares written out beside it. */ private renderBar(locale: string | undefined, d: Constitution) { const c = d.composite; const shares = HUMOURS.map((h) => ({ humour: h, value: c?.[h] ?? 0 })); const total = shares.reduce((n, s) => n + s.value, 0); if (total <= 0) return nothing; return html`

${shares.map( (s) => html`${capitalize( s.humour, )} ${formatPercent(locale, s.value, 0)}`, )}

`; } /** The three scored factors: what each read, the humours it carries, its weight and its verse. */ private renderFactors( locale: string | undefined, factors: Constitution['factors'], ) { const rows = factors ?? []; if (rows.length === 0) return nothing; return html`

${this.t('Factors')}

    ${rows.map( (f) => html`
  • ${this.factorLabel(f.id)}
    ${formatInput(locale, f.input)} ${ f.doshas?.length ? html` · ${f.doshas.map((h) => capitalize(h)).join(', ')}` : nothing } ${ typeof f.weight === 'number' ? html` · ${this.t('Weight')}${formatNumber(locale, f.weight, 2)}` : nothing } ${this.renderCitation(f.source)}
  • `, )}
`; } /** The heading for one factor, falling back to the wire id for a fourth the API might add. */ private factorLabel(id: string | undefined): string { const source = FACTOR_LABEL[id ?? '']; return source ? this.t(source) : humanize(id ?? ''); } /** * One citation, plus any disagreement the response records against it. * * @remarks * A `note` here is a recorded conflict between two editions or a stated limit on what the * citation covers. It is provenance rather than interpretation, so `hide-readings` keeps it: a * page showing the calculation without the report still has to say what the calculation rests on. * * **It is also editorial rather than practical, and it is a paragraph.** Three of them under three * factors is most of the card, above the reading, for a fact a reader consults once and an editor * consults often. So the citation LINE stays on screen, which is what makes the value checkable, * and the paragraph behind it opens on demand. */ private renderCitation(source: Citation | undefined) { const line = citationLine(source ?? {}); if (!line) return nothing; return html`

${this.t('Source')}${line}

${ source?.note ? html`
${this.t('Note on the source')}${chevron()}

${source.note}

` : nothing }
`; } /** The seven grahas by shadbala, with the ones that reached the cutoff marked. */ private renderRanking( locale: string | undefined, ranking: Constitution['strengthRanking'], ) { const rows = ranking ?? []; if (rows.length === 0) return nothing; return html`

${this.t('Shadbala')}

${rows.map( (r) => html``, )}
${this.t('Rank')} ${this.t('Graha')} ${this.t('Strength')} ${this.t('Strong')}
${formatInteger(locale, r.rank)} ${r.graha ?? ''} ${this.t('{{value}} virupas', { value: formatNumber(locale, r.totalVirupas, 2), })} ${r.strong ? this.t('Yes') : this.t('No')}
`; } /** The graha to humour and graha to constituent table the response ships as reference. */ private renderPlanetDoshas(rows: Constitution['planetDoshas']) { const table = rows ?? []; if (table.length === 0) return nothing; return html`

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

${table.map( (r) => html``, )}
${this.t('Graha')} ${this.t('Humours')} ${this.t('Dhatu')}
${r.graha ?? ''} ${(r.doshas ?? []).map((h) => capitalize(h)).join(', ')} ${[r.dhatu, r.dhatuSanskrit].filter(Boolean).join(' · ')}
${this.renderTableSources(table)}
`; } /** * The works behind the reference table, listed once. * * @remarks * Every row carries the same two citations, so printing them per row would repeat one fact seven * times. They are collected by their rendered line, which is what makes the set distinct without * this component having to know which fields identify an edition. */ private renderTableSources(rows: NonNullable) { const lines = new Set(); for (const r of rows) { for (const source of [r.doshaSource, r.dhatuSource]) { const line = citationLine(source ?? {}); if (line) lines.add(line); } } if (lines.size === 0) return nothing; return html`

${this.t('Source')}${[...lines].join(' · ')}

`; } } declare global { interface HTMLElementTagNameMap { 'roxy-dosha-constitution': RoxyDoshaConstitution; } }