| import { Panel } from './Panel'; |
| import type { FredSeries, BisData } from '@/services/economic'; |
| import { BLS_METRO_IDS } from '@/services/economic'; |
| import { t } from '@/services/i18n'; |
| import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; |
| import { isDesktopRuntime } from '@/services/runtime'; |
| import { isFeatureAvailable } from '@/services/runtime-config'; |
| import type { SpendingSummary } from '@/services/usa-spending'; |
| import { formatAwardAmount, getAwardTypeIcon } from '@/services/usa-spending'; |
| import { getCSSColor } from '@/utils'; |
| import { sparkline } from '@/utils/sparkline'; |
| import type { GetEconomicStressResponse, EconomicStressComponent } from '@/generated/client/worldmonitor/economic/v1/service_client'; |
|
|
| type TabId = 'indicators' | 'spending' | 'centralBanks' | 'labor' | 'stress'; |
|
|
| function stressScoreColor(score: number): string { |
| if (score < 20) return '#27ae60'; |
| if (score < 40) return '#f1c40f'; |
| if (score < 60) return '#e67e22'; |
| if (score < 80) return '#e74c3c'; |
| return '#8e44ad'; |
| } |
|
|
| function stressFormatRaw(id: string, raw: number): string { |
| if (id === 'ICSA') return raw >= 1000 ? (raw / 1000).toFixed(0) + 'K' : raw.toFixed(0); |
| if (id === 'VIXCLS') return raw.toFixed(2); |
| if (id === 'STLFSI4' || id === 'GSCPI') return raw.toFixed(3); |
| return raw.toFixed(2); |
| } |
|
|
| const STRESS_NOTIFICATION_KEY = 'wm:economic-stress:last-notified-level'; |
|
|
| function notifyIfStressCrossed(score: number): void { |
| if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; |
| const level = score >= 85 ? 2 : score >= 70 ? 1 : 0; |
| if (level === 0) return; |
| try { |
| const lastLevel = parseInt(sessionStorage.getItem(STRESS_NOTIFICATION_KEY) ?? '0', 10); |
| if (level <= lastLevel) return; |
| sessionStorage.setItem(STRESS_NOTIFICATION_KEY, String(level)); |
| new Notification('Economic Stress Alert', { |
| body: `Composite stress index reached ${score.toFixed(1)} (${score >= 85 ? 'Critical' : 'Severe'})`, |
| icon: '/favico/favicon-32x32.png', |
| tag: 'economic-stress', |
| }); |
| } catch { } |
| } |
|
|
| function stressComponentCard(c: EconomicStressComponent): string { |
| if (c.missing) { |
| return `<div style="background:rgba(255,255,255,0.02);border-radius:6px;padding:8px 10px;border:1px solid rgba(255,255,255,0.05)"> |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> |
| <span style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em">${escapeHtml(c.label)}</span> |
| <span style="font-size:10px;color:#888">N/A</span> |
| </div> |
| <div style="font-size:9px;color:#666;font-style:italic">Data unavailable</div> |
| </div>`; |
| } |
| const color = stressScoreColor(c.score); |
| const barWidth = Math.min(100, Math.max(0, c.score)).toFixed(1); |
| const rawDisplay = stressFormatRaw(c.id, c.rawValue); |
| return `<div style="background:rgba(255,255,255,0.04);border-radius:6px;padding:8px 10px;border:1px solid rgba(255,255,255,0.07)"> |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> |
| <span style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em">${escapeHtml(c.label)}</span> |
| <span style="font-size:10px;color:var(--text-dim)">${escapeHtml(rawDisplay)}</span> |
| </div> |
| <div style="display:flex;align-items:center;gap:6px"> |
| <div style="flex:1;background:rgba(255,255,255,0.07);border-radius:3px;height:5px;overflow:hidden"> |
| <div style="height:100%;width:${barWidth}%;background:${color};border-radius:3px;transition:width 0.3s"></div> |
| </div> |
| <span style="font-size:10px;font-weight:600;color:${color};min-width:28px;text-align:right">${c.score.toFixed(0)}</span> |
| </div> |
| </div>`; |
| } |
|
|
| function formatSeriesValue(series: FredSeries): string { |
| if (series.value === null) return 'N/A'; |
| if (series.unit === '$B') return `$${series.value.toLocaleString()}B`; |
| return `${series.value.toLocaleString()}${series.unit}`; |
| } |
|
|
| function formatSeriesChange(series: FredSeries): string { |
| if (series.change === null) return 'No change'; |
| const sign = series.change > 0 ? '+' : ''; |
| if (series.unit === '$B') { |
| const prefix = series.change < 0 ? '-$' : `${sign}$`; |
| return `${prefix}${Math.abs(series.change).toLocaleString()}B`; |
| } |
| return `${sign}${series.change.toLocaleString()}${series.unit}`; |
| } |
|
|
| function getSeriesChangeClass(change: number | null): string { |
| if (change === null || change === 0) return 'neutral'; |
| return change > 0 ? 'positive' : 'negative'; |
| } |
|
|
| function getMacroPressure(data: FredSeries[]): { |
| label: string; |
| detail: string; |
| className: string; |
| } { |
| const byId = new Map(data.map((series) => [series.id, series])); |
| const vix = byId.get('VIXCLS')?.value ?? null; |
| const curve = byId.get('T10Y2Y')?.value ?? null; |
| const unemployment = byId.get('UNRATE')?.value ?? null; |
| const fedFunds = byId.get('FEDFUNDS')?.value ?? null; |
|
|
| let score = 0; |
| if (vix !== null) score += vix >= 25 ? 2 : vix >= 18 ? 1 : 0; |
| if (curve !== null) score += curve <= 0 ? 2 : curve < 0.5 ? 1 : 0; |
| if (unemployment !== null) score += unemployment >= 4.5 ? 1 : 0; |
| if (fedFunds !== null) score += fedFunds >= 5 ? 1 : fedFunds <= 2 ? -1 : 0; |
|
|
| if (score >= 4) { |
| return { |
| label: t('components.economic.pressure.stress'), |
| detail: t('components.economic.pressure.stressDetail'), |
| className: 'macro-pressure-stress', |
| }; |
| } |
| if (score >= 2) { |
| return { |
| label: t('components.economic.pressure.watch'), |
| detail: t('components.economic.pressure.watchDetail'), |
| className: 'macro-pressure-watch', |
| }; |
| } |
| return { |
| label: t('components.economic.pressure.steady'), |
| detail: t('components.economic.pressure.steadyDetail'), |
| className: 'macro-pressure-steady', |
| }; |
| } |
|
|
| type FredLoadState = 'loading' | 'ok' | 'error' | 'retrying'; |
|
|
| export class EconomicPanel extends Panel { |
| private fredData: FredSeries[] = []; |
| private blsData: FredSeries[] = []; |
| private spendingData: SpendingSummary | null = null; |
| private bisData: BisData | null = null; |
| private stressData: GetEconomicStressResponse | null = null; |
| private lastUpdate: Date | null = null; |
| private activeTab: TabId = 'indicators'; |
| private fredState: FredLoadState = 'loading'; |
| private fredErrorMsg = ''; |
|
|
| constructor() { |
| super({ |
| id: 'economic', |
| title: t('panels.economic'), |
| defaultRowSpan: 2, |
| infoTooltip: t('components.economic.infoTooltip'), |
| }); |
| this.content.addEventListener('click', (e) => { |
| const tab = (e.target as HTMLElement).closest('.panel-tab') as HTMLElement | null; |
| if (tab?.dataset.tab) { |
| this.activeTab = tab.dataset.tab as TabId; |
| this.render(); |
| } |
| }); |
| } |
|
|
| public update(data: FredSeries[]): void { |
| this.fredData = data; |
| this.fredState = 'ok'; |
| this.fredErrorMsg = ''; |
| this.lastUpdate = new Date(); |
| this.render(); |
| } |
|
|
| public setFredError(message: string): void { |
| this.fredState = 'error'; |
| this.fredErrorMsg = message; |
| this.render(); |
| } |
|
|
| public setFredRetrying(remainingSeconds?: number): void { |
| this.fredState = 'retrying'; |
| this.fredErrorMsg = remainingSeconds !== undefined |
| ? `${t('common.retrying')} (${remainingSeconds}s)` |
| : t('common.retrying'); |
| this.render(); |
| } |
|
|
| public updateSpending(data: SpendingSummary): void { |
| this.spendingData = data; |
| this.render(); |
| } |
|
|
| public updateBis(data: BisData): void { |
| this.bisData = data; |
| this.render(); |
| } |
|
|
| public updateBls(data: FredSeries[]): void { |
| this.blsData = data; |
| this.render(); |
| } |
|
|
| public updateStress(data: GetEconomicStressResponse): void { |
| this.stressData = data; |
| if (Number.isFinite(data.compositeScore)) notifyIfStressCrossed(data.compositeScore); |
| this.render(); |
| } |
|
|
| public setLoading(loading: boolean): void { |
| if (loading) { |
| this.fredState = 'loading'; |
| this.fredErrorMsg = ''; |
| } |
| } |
|
|
| private render(): void { |
| const hasSpending = this.spendingData && this.spendingData.awards?.length > 0; |
| const hasBis = this.bisData && this.bisData.policyRates?.length > 0; |
| const hasBls = this.blsData.length > 0; |
|
|
| const tabsHtml = ` |
| <div class="panel-tabs"> |
| <button class="panel-tab ${this.activeTab === 'indicators' ? 'active' : ''}" data-tab="indicators"> |
| ${t('components.economic.indicators')} |
| </button> |
| ${hasSpending ? ` |
| <button class="panel-tab ${this.activeTab === 'spending' ? 'active' : ''}" data-tab="spending"> |
| Recent awards |
| </button> |
| ` : ''} |
| ${hasBis ? ` |
| <button class="panel-tab ${this.activeTab === 'centralBanks' ? 'active' : ''}" data-tab="centralBanks"> |
| ${t('components.economic.centralBanks')} |
| </button> |
| ` : ''} |
| ${hasBls ? ` |
| <button class="panel-tab ${this.activeTab === 'labor' ? 'active' : ''}" data-tab="labor"> |
| ${t('components.economic.laborMarket')} |
| </button> |
| ` : ''} |
| <button class="panel-tab ${this.activeTab === 'stress' ? 'active' : ''}" data-tab="stress"> |
| Stress Index |
| </button> |
| </div> |
| `; |
|
|
| let contentHtml = ''; |
| switch (this.activeTab) { |
| case 'indicators': |
| contentHtml = this.renderIndicators(); |
| break; |
| case 'spending': |
| contentHtml = this.renderSpending(); |
| break; |
| case 'centralBanks': |
| contentHtml = this.renderCentralBanks(); |
| break; |
| case 'labor': |
| contentHtml = this.renderLabor(); |
| break; |
| case 'stress': |
| contentHtml = this.renderStress(); |
| break; |
| } |
|
|
| const updateTime = this.lastUpdate |
| ? this.lastUpdate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) |
| : ''; |
|
|
| this.setSafeContent(unsafeRawHtml(` |
| ${tabsHtml} |
| <div class="economic-content"> |
| ${contentHtml} |
| </div> |
| <div class="economic-footer"> |
| <span class="economic-source">${this.getSourceLabel()} • ${updateTime}</span> |
| </div> |
| `, 'legacy Panel.setContent() migration')); |
| } |
|
|
| private getSourceLabel(): string { |
| switch (this.activeTab) { |
| case 'indicators': return 'FRED'; |
| case 'spending': return 'USASpending.gov'; |
| case 'centralBanks': return 'BIS'; |
| case 'labor': return 'BLS'; |
| case 'stress': return 'FRED'; |
| } |
| } |
|
|
| private renderIndicators(): string { |
| if (this.fredData.length === 0) { |
| if (isDesktopRuntime() && !isFeatureAvailable('economicFred')) { |
| return `<div class="economic-empty">${t('components.economic.fredKeyMissing')}</div>`; |
| } |
| if (this.fredState === 'error' || this.fredState === 'retrying') { |
| const isRetrying = this.fredState === 'retrying'; |
| const raw = isRetrying ? t('common.upstreamUnavailable') : this.fredErrorMsg; |
| const mainMsg = raw.includes('\u2014') ? raw.slice(0, raw.indexOf('\u2014')).trimEnd() : raw; |
| const countdownLine = isRetrying ? `<div class="panel-error-countdown">${escapeHtml(this.fredErrorMsg)}</div>` : ''; |
| return ` |
| <div class="panel-error-state"> |
| <div class="panel-loading-radar panel-error-radar"> |
| <div class="panel-radar-sweep"></div> |
| <div class="panel-radar-dot error"></div> |
| </div> |
| <div class="panel-error-msg">${escapeHtml(mainMsg)}</div> |
| ${countdownLine} |
| </div> |
| `; |
| } |
| return `<div class="economic-empty">${t('components.economic.noIndicatorData')}</div>`; |
| } |
|
|
| const pressure = getMacroPressure(this.fredData); |
| const summaryIds = ['VIXCLS', 'T10Y2Y', 'FEDFUNDS', 'UNRATE']; |
| const summarySeries = this.fredData.filter((series) => summaryIds.includes(series.id)); |
| const detailSeries = this.fredData.filter((series) => !summaryIds.includes(series.id)); |
| const orderedSeries = [...summarySeries, ...detailSeries]; |
|
|
| return ` |
| <div class="economic-content-macro"> |
| <div class="macro-pressure-card ${pressure.className}"> |
| <div class="macro-pressure-label">${t('components.economic.pressure.label')}</div> |
| <div class="macro-pressure-value">${escapeHtml(pressure.label)}</div> |
| <div class="macro-pressure-detail">${escapeHtml(pressure.detail)}</div> |
| </div> |
| <div class="macro-summary-grid"> |
| ${summarySeries.map((series) => ` |
| <div class="macro-summary-card"> |
| <div class="macro-summary-head"> |
| <span class="indicator-name">${escapeHtml(series.name)}</span> |
| <span class="indicator-id">${escapeHtml(series.id)}</span> |
| </div> |
| <div class="macro-summary-value">${escapeHtml(formatSeriesValue(series))}</div> |
| <div class="macro-summary-change ${getSeriesChangeClass(series.change)}">${escapeHtml(formatSeriesChange(series))}</div> |
| </div> |
| `).join('')} |
| </div> |
| <div class="economic-indicators"> |
| ${orderedSeries.map((series) => ` |
| <div class="economic-indicator" data-series="${escapeHtml(series.id)}"> |
| <div class="indicator-header"> |
| <span class="indicator-name">${escapeHtml(series.name)}</span> |
| <span class="indicator-id">${escapeHtml(series.id)}</span> |
| </div> |
| <div class="indicator-value"> |
| <span class="value">${escapeHtml(formatSeriesValue(series))}</span> |
| <span class="change ${getSeriesChangeClass(series.change)}">${escapeHtml(formatSeriesChange(series))}</span> |
| </div> |
| <div class="indicator-date">${escapeHtml(series.date)}</div> |
| ${sparkline(series.observations?.map(o => o.value) ?? [], series.change !== null && series.change >= 0 ? '#4caf50' : '#f44336', 120, 28, 'display:block;margin:2px 0')} |
| </div> |
| `).join('')} |
| </div> |
| </div> |
| `; |
| } |
|
|
| private renderSpending(): string { |
| if (!this.spendingData || !this.spendingData.awards?.length) { |
| return `<div class="economic-empty">${t('components.economic.noSpending')}</div>`; |
| } |
|
|
| const { awards, totalAmount, periodStart, periodEnd } = this.spendingData; |
|
|
| return ` |
| <div class="spending-summary"> |
| <div class="spending-total"> |
| ${escapeHtml(formatAwardAmount(totalAmount))} ${t('components.economic.in')} ${escapeHtml(String(awards.length))} ${t('components.economic.awards')} |
| <span class="spending-period">${escapeHtml(periodStart)} / ${escapeHtml(periodEnd)}</span> |
| </div> |
| </div> |
| <div class="spending-list"> |
| ${awards.slice(0, 8).map(award => ` |
| <div class="spending-award"> |
| <div class="award-header"> |
| <span class="award-icon">${escapeHtml(getAwardTypeIcon(award.awardType))}</span> |
| <span class="award-amount">${escapeHtml(formatAwardAmount(award.amount))}</span> |
| </div> |
| <div class="award-recipient">${escapeHtml(award.recipientName)}</div> |
| <div class="award-agency">${escapeHtml(award.agency)}</div> |
| ${award.description ? `<div class="award-desc">${escapeHtml(award.description.slice(0, 100))}${award.description.length > 100 ? '...' : ''}</div>` : ''} |
| </div> |
| `).join('')} |
| </div> |
| `; |
| } |
|
|
| private renderCentralBanks(): string { |
| if (!this.bisData || !this.bisData.policyRates?.length) { |
| return `<div class="economic-empty">${t('components.economic.noBisData')}</div>`; |
| } |
|
|
| const greenColor = getCSSColor('--semantic-normal'); |
| const redColor = getCSSColor('--semantic-critical'); |
| const neutralColor = getCSSColor('--text-dim'); |
|
|
| const sortedRates = [...this.bisData.policyRates].sort((a, b) => b.rate - a.rate); |
| const policyHtml = ` |
| <div class="bis-section"> |
| <div class="bis-section-title">${t('components.economic.policyRate')}</div> |
| <div class="economic-indicators"> |
| ${sortedRates.map(r => { |
| const diff = r.rate - r.previousRate; |
| const color = diff < 0 ? greenColor : diff > 0 ? redColor : neutralColor; |
| const label = diff < 0 ? t('components.economic.cut') : diff > 0 ? t('components.economic.hike') : t('components.economic.hold'); |
| const arrow = diff < 0 ? '▼' : diff > 0 ? '▲' : '–'; |
| return ` |
| <div class="economic-indicator"> |
| <div class="indicator-header"> |
| <span class="indicator-name">${escapeHtml(r.centralBank)}</span> |
| <span class="indicator-id">${escapeHtml(r.countryCode)}</span> |
| </div> |
| <div class="indicator-value"> |
| <span class="value">${escapeHtml(String(r.rate))}%</span> |
| <span class="change" style="color: ${escapeHtml(color)}">${escapeHtml(arrow)} ${escapeHtml(label)}</span> |
| </div> |
| <div class="indicator-date">${escapeHtml(r.date)}</div> |
| </div>`; |
| }).join('')} |
| </div> |
| </div> |
| `; |
|
|
| let eerHtml = ''; |
| if (this.bisData.exchangeRates?.length > 0) { |
| eerHtml = ` |
| <div class="bis-section"> |
| <div class="bis-section-title">${t('components.economic.realEer')}</div> |
| <div class="economic-indicators"> |
| ${this.bisData.exchangeRates.map(r => { |
| const color = r.realChange > 0 ? redColor : r.realChange < 0 ? greenColor : neutralColor; |
| const arrow = r.realChange > 0 ? '▲' : r.realChange < 0 ? '▼' : '–'; |
| return ` |
| <div class="economic-indicator"> |
| <div class="indicator-header"> |
| <span class="indicator-name">${escapeHtml(r.countryName)}</span> |
| <span class="indicator-id">${escapeHtml(r.countryCode)}</span> |
| </div> |
| <div class="indicator-value"> |
| <span class="value">${escapeHtml(String(r.realEer))}</span> |
| <span class="change" style="color: ${escapeHtml(color)}">${escapeHtml(arrow)} ${escapeHtml(String(r.realChange > 0 ? '+' : ''))}${escapeHtml(String(r.realChange))}%</span> |
| </div> |
| <div class="indicator-date">${escapeHtml(r.date)}</div> |
| </div>`; |
| }).join('')} |
| </div> |
| </div> |
| `; |
| } |
|
|
| let creditHtml = ''; |
| if (this.bisData.creditToGdp?.length > 0) { |
| const sortedCredit = [...this.bisData.creditToGdp].sort((a, b) => b.creditGdpRatio - a.creditGdpRatio); |
| creditHtml = ` |
| <div class="bis-section"> |
| <div class="bis-section-title">${t('components.economic.creditToGdp')}</div> |
| <div class="economic-indicators"> |
| ${sortedCredit.map(r => { |
| const diff = r.creditGdpRatio - r.previousRatio; |
| const color = diff > 0 ? redColor : diff < 0 ? greenColor : neutralColor; |
| const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'; |
| const changeStr = diff !== 0 ? `${diff > 0 ? '+' : ''}${(Math.round(diff * 10) / 10)}pp` : '–'; |
| return ` |
| <div class="economic-indicator"> |
| <div class="indicator-header"> |
| <span class="indicator-name">${escapeHtml(r.countryName)}</span> |
| <span class="indicator-id">${escapeHtml(r.countryCode)}</span> |
| </div> |
| <div class="indicator-value"> |
| <span class="value">${escapeHtml(String(r.creditGdpRatio))}%</span> |
| <span class="change" style="color: ${escapeHtml(color)}">${escapeHtml(arrow)} ${escapeHtml(changeStr)}</span> |
| </div> |
| <div class="indicator-date">${escapeHtml(r.date)}</div> |
| </div>`; |
| }).join('')} |
| </div> |
| </div> |
| `; |
| } |
|
|
| return policyHtml + eerHtml + creditHtml; |
| } |
|
|
| private renderLabor(): string { |
| if (this.blsData.length === 0) { |
| return `<div class="economic-empty">${t('components.economic.noIndicatorData')}</div>`; |
| } |
|
|
| const national = this.blsData.filter(s => !BLS_METRO_IDS.has(s.id)); |
| const metro = this.blsData.filter(s => BLS_METRO_IDS.has(s.id)); |
|
|
| const seriesRow = (series: FredSeries): string => ` |
| <div class="economic-indicator" data-series="${escapeHtml(series.id)}"> |
| <div class="indicator-header"> |
| <span class="indicator-name">${escapeHtml(series.name)}</span> |
| <span class="indicator-id">${escapeHtml(series.id)}</span> |
| </div> |
| <div class="indicator-value"> |
| <span class="value">${escapeHtml(formatSeriesValue(series))}</span> |
| <span class="change ${getSeriesChangeClass(series.change)}">${escapeHtml(formatSeriesChange(series))}</span> |
| </div> |
| <div class="indicator-date">${escapeHtml(series.date)}</div> |
| ${sparkline(series.observations?.map(o => o.value) ?? [], series.change !== null && series.change >= 0 ? '#4caf50' : '#f44336', 120, 28, 'display:block;margin:2px 0')} |
| </div>`; |
|
|
| return ` |
| <div class="economic-content-macro"> |
| <div class="economic-indicators"> |
| ${national.map(seriesRow).join('')} |
| </div> |
| ${metro.length > 0 ? ` |
| <div class="bis-section"> |
| <div class="bis-section-title">${t('components.economic.metroUnemployment')}</div> |
| <div class="economic-indicators"> |
| ${metro.map(seriesRow).join('')} |
| </div> |
| </div> |
| ` : ''} |
| </div> |
| `; |
| } |
|
|
| private renderStress(): string { |
| const d = this.stressData; |
| if (!d || d.unavailable || !Number.isFinite(d.compositeScore)) { |
| return `<div class="economic-empty">Stress index data unavailable</div>`; |
| } |
|
|
| const color = stressScoreColor(d.compositeScore); |
| const needlePct = Math.min(100, Math.max(0, d.compositeScore)).toFixed(1); |
| const cards = d.components.map((c) => stressComponentCard(c)).join(''); |
| const updatedNote = d.seededAt |
| ? `<div style="font-size:9px;color:var(--text-dim);text-align:right;margin-top:8px">Updated ${new Date(d.seededAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</div>` |
| : ''; |
|
|
| return `<div style="padding:12px 14px"> |
| <div style="text-align:center;margin-bottom:12px"> |
| <div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:4px">Composite Score</div> |
| <div style="font-size:38px;font-weight:700;color:${color};line-height:1">${d.compositeScore.toFixed(1)}</div> |
| <div style="display:inline-block;margin-top:6px;padding:3px 10px;border-radius:12px;background:${color}22;border:1px solid ${color}66;font-size:12px;font-weight:600;color:${color}">${escapeHtml(d.label)}</div> |
| </div> |
| <div style="margin-bottom:16px"> |
| <div style="position:relative;height:12px;border-radius:6px;overflow:visible;background:linear-gradient(to right,#27ae60 0%,#f1c40f 20%,#e67e22 40%,#e74c3c 60%,#8e44ad 80%,#8e44ad 100%);margin-bottom:4px"> |
| <div style="position:absolute;top:-4px;left:calc(${needlePct}% - 2px);width:4px;height:20px;background:#fff;border-radius:2px;box-shadow:0 0 4px rgba(0,0,0,0.6)"></div> |
| </div> |
| <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim)"> |
| <span>Low</span><span>Moderate</span><span>Elevated</span><span>Severe</span><span>Critical</span> |
| </div> |
| </div> |
| ${cards ? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px">${cards}</div>` : ''} |
| ${updatedNote} |
| </div>`; |
| } |
| } |
|
|