function TrendChart({ data, width = 600, height = 240, valueFormatter }) { const containerStyle = { width: "100%", display: "flex", flexDirection: "column", gap: "12px", }; const renderEmptyChart = () => (
); const safeData = Array.isArray(data) ? data : []; if (safeData.length === 0) { return renderEmptyChart(); } const firstItem = safeData[0]; const isSeriesCollection = Array.isArray(firstItem?.values); const normalizeValue = (value) => { const number = Number(value); return Number.isFinite(number) ? number : 0; }; let normalizedSeries; if (isSeriesCollection) { normalizedSeries = safeData .map((series, index) => { const values = Array.isArray(series?.values) ? series.values : []; return { key: (series && (series.key || series.handle || series.name || series.label)) || `series-${index}`, name: String( (series && (series.name || series.label || series.handle || series.key)) || `Series ${index + 1}`, ), color: series && series.color, values: values.map((point) => ({ date: point?.date, total: normalizeValue(point?.total), })), }; }) .filter((series) => Array.isArray(series.values) && series.values.length > 0); } else { normalizedSeries = [ { key: (firstItem && (firstItem.key || firstItem.name || firstItem.label)) || "series-0", name: String( (firstItem && (firstItem.name || firstItem.label || firstItem.key)) || "Total", ), values: safeData.map((point) => ({ date: point?.date, total: normalizeValue(point?.total), })), }, ]; } if (!normalizedSeries || normalizedSeries.length === 0) { return renderEmptyChart(); } const colorPalette = [ "#2563eb", "#16a34a", "#f97316", "#9333ea", "#0ea5e9", "#dc2626", "#14b8a6", "#facc15", "#a855f7", "#4b5563", ]; const seriesToRender = normalizedSeries.map((series, index) => ({ ...series, color: series.color || colorPalette[index % colorPalette.length], })); const pointCount = seriesToRender.reduce( (max, series) => Math.max(max, Array.isArray(series.values) ? series.values.length : 0), 0, ); if (pointCount === 0) { return renderEmptyChart(); } const allTotals = seriesToRender.flatMap((series) => Array.isArray(series.values) ? series.values.map((point) => point.total) : [], ); let domainMin = Math.min(0, ...allTotals); let domainMax = Math.max(0, ...allTotals); if (!Number.isFinite(domainMin)) domainMin = 0; if (!Number.isFinite(domainMax)) domainMax = 0; if (domainMax === domainMin) { const domainPadding = Math.abs(domainMax || 1) * 0.1 || 1; domainMax += domainPadding; domainMin -= domainPadding; } const range = domainMax - domainMin || 1; const hasMultipleSeries = seriesToRender.length > 1; const padding = hasMultipleSeries ? { top: 32, right: 24, bottom: 56, left: 64 } : { top: 24, right: 24, bottom: 48, left: 64 }; const chartWidth = Math.max(1, width - padding.left - padding.right); const chartHeight = Math.max(1, height - padding.top - padding.bottom); const scaleX = (index) => { if (pointCount === 1) { return padding.left + chartWidth / 2; } const t = index / (pointCount - 1); return padding.left + t * chartWidth; }; const scaleY = (value) => { const normalized = (value - domainMin) / range; const clamped = Math.max(0, Math.min(1, normalized)); return padding.top + chartHeight - clamped * chartHeight; }; const fallbackFormatter = (value) => { if (typeof valueFormatter === "function") { return valueFormatter(value); } if (typeof dollars === "function") { return dollars(value); } const number = Number(value) || 0; return `$${number.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; }; const zeroY = scaleY(0); const parseDate = (value) => { if (value instanceof Date && Number.isFinite(value.getTime())) return value; if (typeof value === "number" && Number.isFinite(value)) { const date = new Date(value); if (Number.isFinite(date.getTime())) return date; } if (typeof value === "string" && value) { const parts = value.split("-"); if (parts.length === 3) { const year = Number(parts[0]); const month = Number(parts[1]) - 1; const day = Number(parts[2]); if ( Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day) ) { const date = new Date(year, month, day); if (Number.isFinite(date.getTime())) return date; } } const date = new Date(value); if (Number.isFinite(date.getTime())) return date; } return null; }; const formatDateLabel = (value, fallback) => { const date = parseDate(value); if (date) { try { return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", }).format(date); } catch (e) { return `${date.getMonth() + 1}/${date.getDate()}`; } } if (typeof fallback === "number") { return `#${fallback + 1}`; } return value == null ? "" : String(value); }; const buildSeriesLine = (values) => { if (!values || values.length === 0) return ""; if (pointCount === 1) { const y = scaleY(values[0]?.total || 0); return `M${padding.left},${y} L${padding.left + chartWidth},${y}`; } return values .map((point, index) => { const x = scaleX(index); const y = scaleY(point.total); return `${index === 0 ? "M" : "L"}${x},${y}`; }) .join(" "); }; let singleAreaPath = ""; if (!hasMultipleSeries) { const singleSeries = seriesToRender[0]; const path = buildSeriesLine(singleSeries.values); singleAreaPath = path ? `${path} L${scaleX(singleSeries.values.length - 1)},${zeroY} L${scaleX(0)},${zeroY} Z` : ""; } const rawTickValues = [domainMax, domainMin, domainMin + range / 2, 0]; const seen = new Set(); const ticks = []; for (const value of rawTickValues) { if (!Number.isFinite(value)) continue; const normalized = Math.abs(value) < 0.005 ? 0 : Number(value.toFixed(2)); const key = normalized.toFixed(2); if (seen.has(key)) continue; seen.add(key); ticks.push({ key, value: normalized, y: scaleY(normalized), label: fallbackFormatter(normalized), isZero: Math.abs(normalized) < 0.0001, }); } ticks.sort((a, b) => b.value - a.value); const chartLeft = padding.left; const chartRight = width - padding.right; const chartTop = padding.top; const chartBottom = padding.top + chartHeight; const referenceSeries = seriesToRender.reduce((acc, series) => { if (!Array.isArray(series?.values)) return acc; if (!acc) return series; return series.values.length > (acc.values?.length || 0) ? series : acc; }, null); const referenceValues = Array.isArray(referenceSeries?.values) ? referenceSeries.values : []; const xTickCount = Math.min(6, referenceValues.length); const usedIndices = new Set(); const xTicks = []; if (xTickCount > 0) { for (let i = 0; i < xTickCount; i += 1) { const t = xTickCount === 1 ? 0 : i / (xTickCount - 1); const rawIndex = (referenceValues.length - 1) * t; const index = Math.round(rawIndex); if (usedIndices.has(index)) continue; usedIndices.add(index); const point = referenceValues[index] || referenceValues[referenceValues.length - 1]; if (!point) continue; const label = formatDateLabel(point.date, index); if (!label) continue; xTicks.push({ index, x: scaleX(index), label }); } } const legendItems = seriesToRender.filter((series) => series.key !== "baseline"); const legend = hasMultipleSeries && legendItems.length > 0 ? (
{legendItems.map((series) => (
{series.name}
))}
) : null; return (
{ticks.map((tick) => ( {!tick.isZero && ( )} {tick.label} ))} {!hasMultipleSeries && singleAreaPath && ( )} {xTicks.map((tick) => ( {tick.label} ))} {seriesToRender.map((series) => { const linePath = buildSeriesLine(series.values); if (!linePath) return null; const isBaseline = series.key === "baseline"; return ( ); })} {seriesToRender.map((series) => { if (!Array.isArray(series.values) || series.key === "baseline") return null; const radius = hasMultipleSeries ? 2.5 : 3; return series.values.map((point, index) => { const x = scaleX(index); const y = scaleY(point.total); return ( ); }); })} {legend}
); } window.TrendChart = TrendChart;