/** * Shared SVG sparkline renderer. * * Used by both the frontend init script (`rate-card-frontend.ts`) * and the Gutenberg editor (`blocks/rate-card/edit.tsx`). */ export interface ChartPoint { ts: number; rate: number; } export function renderSparkline(container: HTMLElement, points: ChartPoint[], ariaLabel = ''): void { container.innerHTML = ''; if (points.length < 3) { container.classList.add('crtoday-chart-empty'); return; } container.classList.remove('crtoday-chart-empty'); const width = 300; const height = 60; const padY = 4; const rates = points.map((p) => p.rate); const min = Math.min(...rates); const max = Math.max(...rates); const range = max - min || 1; const scaleX = (i: number) => (i / (points.length - 1)) * width; const scaleY = (v: number) => height - padY - ((v - min) / range) * (height - padY * 2); /* Build the polyline path */ let d = ''; for (let i = 0; i < points.length; i++) { const x = scaleX(i).toFixed(2); const y = scaleY(points[i].rate).toFixed(2); d += (i === 0 ? `M${x},${y}` : ` L${x},${y}`); } /* Gradient fill path: close along the bottom */ const firstX = scaleX(0).toFixed(2); const lastX = scaleX(points.length - 1).toFixed(2); const fillD = `${d} L${lastX},${height} L${firstX},${height} Z`; /* Determine direction color class */ const first = points[0].rate; const last = points[points.length - 1].rate; const dir = last >= first ? 'up' : 'down'; const ns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('preserveAspectRatio', 'none'); svg.setAttribute('role', 'img'); if (ariaLabel) { svg.setAttribute('aria-label', ariaLabel); } svg.classList.add('crtoday-sparkline', `crtoday-sparkline-${dir}`); /* Gradient definition */ const defs = document.createElementNS(ns, 'defs'); const gradId = `crtoday-sg-${Math.random().toString(36).slice(2, 8)}`; const grad = document.createElementNS(ns, 'linearGradient'); grad.setAttribute('id', gradId); grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0'); grad.setAttribute('x2', '0'); grad.setAttribute('y2', '1'); const stop1 = document.createElementNS(ns, 'stop'); stop1.setAttribute('offset', '0%'); stop1.setAttribute('class', 'crtoday-sparkline-grad-start'); const stop2 = document.createElementNS(ns, 'stop'); stop2.setAttribute('offset', '100%'); stop2.setAttribute('class', 'crtoday-sparkline-grad-end'); grad.appendChild(stop1); grad.appendChild(stop2); defs.appendChild(grad); svg.appendChild(defs); /* Fill area */ const fillPath = document.createElementNS(ns, 'path'); fillPath.setAttribute('d', fillD); fillPath.setAttribute('fill', `url(#${gradId})`); fillPath.classList.add('crtoday-sparkline-fill'); svg.appendChild(fillPath); /* Line */ const linePath = document.createElementNS(ns, 'path'); linePath.setAttribute('d', d); linePath.setAttribute('fill', 'none'); linePath.classList.add('crtoday-sparkline-line'); svg.appendChild(linePath); container.appendChild(svg); } /** * Initialize all chart wraps within a root element. * Reads `data-crtoday-chart-points` JSON and renders the sparkline. */ export function initChartsIn(root: ParentNode): void { const INIT_FLAG = 'data-crtoday-chart-init'; root.querySelectorAll('.crtoday-chart-wrap').forEach((wrap) => { if (wrap.hasAttribute(INIT_FLAG)) return; wrap.setAttribute(INIT_FLAG, '1'); const area = wrap.querySelector('.crtoday-chart-area'); const raw = wrap.getAttribute('data-crtoday-chart-points'); const chartLabel = wrap.getAttribute('data-crtoday-i18n-chart') || ''; if (area && raw) { try { const points: ChartPoint[] = JSON.parse(raw); if (points.length >= 3) { renderSparkline(area, points, chartLabel); } } catch { /* malformed JSON — skip */ } } }); }