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 (
{legend}
);
}
window.TrendChart = TrendChart;