Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 163 additions & 20 deletions apps/web/src/app/(staticPages)/creator-economy/_components/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ export function ColumnChart({
const plotH = H - padTop - padBottom;
const groups = labels.length;
const groupW = (W - padX * 2) / groups;
const barW = Math.min(24, (groupW - 16) / series.length); // mark spec: <=24px
const barW = Math.min(24, Math.max(6, (groupW - 8) / series.length - 2)); // mark spec: <=24px
const gap = 2; // surface gap between grouped bars
const max = scaleMax(series.flatMap((s) => s.values));
// Selective labeling for dense charts: at many groups the x-axis shows only
// the labels the caller kept (pass "" to skip), fonts shrink a step, and
// multi-series cap values are dropped entirely (legend + table carry them).
const dense = groups > 8;
const fontSize = dense ? 10 : 12;
const showCapValues = series.length === 1 || !dense;

return (
<div>
Expand Down Expand Up @@ -70,7 +76,7 @@ export function ColumnChart({
const clusterW = series.length * barW + (series.length - 1) * gap;
const startX = groupX + (groupW - clusterW) / 2;
return (
<g key={label}>
<g key={`${label}-${gi}`}>
{series.map((s, si) => {
const v = s.values[gi] ?? 0;
const h = Math.round((v / max) * plotH);
Expand All @@ -85,27 +91,164 @@ export function ColumnChart({
the visible cap labels and the table views. */}
<path d={columnPath(x, yTop, barW, h)} fill={seriesColor(si)} aria-hidden="true" />
{/* direct label on the cap; text token, not series color */}
<text
x={x + barW / 2}
y={yTop - 6}
textAnchor="middle"
fontSize={12}
fill="var(--ce-text2)"
>
{valueFormatter(v)}
</text>
{showCapValues && (
<text
x={x + barW / 2}
y={yTop - 6}
textAnchor="middle"
fontSize={fontSize}
fill="var(--ce-text2)"
>
{valueFormatter(v)}
</text>
)}
</g>
);
})}
<text
x={groupX + groupW / 2}
y={H - padBottom + 18}
textAnchor="middle"
fontSize={12}
fill="var(--ce-text2)"
>
{label}
</text>
{label && (
<text
x={groupX + groupW / 2}
y={H - padBottom + 18}
textAnchor="middle"
fontSize={fontSize}
fill="var(--ce-text2)"
>
{label}
</text>
)}
</g>
);
})}
</svg>
</div>
);
}

/**
* Trend line for dense series (>12 points, where columns stop working).
* Mark specs: 2px round-join line, >=8px end dot with a 2px surface ring,
* ~10% area wash for a single series. Labels are selective (first / peak /
* last values; year ticks on the x-axis) - the table views carry every value.
*/
export function LineChart({
labels,
series,
ariaLabel,
valueFormatter = formatCompact
}: {
labels: string[]; // one per point, e.g. "Q2 2020"
series: ColumnSeries[]; // 1-2 series
ariaLabel: string;
valueFormatter?: (n: number) => string;
}) {
const W = 560;
const H = 230;
const padX = 10;
const padTop = 26;
const padBottom = 26;
const plotH = H - padTop - padBottom;
const plotW = W - padX * 2;
const n = labels.length;
const max = scaleMax(series.flatMap((s) => s.values));
const px = (i: number) => padX + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW);
const py = (v: number) => H - padBottom - (v / max) * plotH;

// Year ticks: first point of each year (labels like "Q1 2023"), plus the
// very first point when it starts mid-year.
const yearTicks = labels
.map((l, i) => ({ l, i }))
.filter(({ l, i }) => i === 0 || l.startsWith("Q1 "))
.map(({ l, i }) => ({ i, text: l.split(" ")[1] ?? l }));
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return (
<div>
{series.length > 1 && (
<div className="flex items-center gap-4 mb-2 text-sm text-gray-600 dark:text-gray-400">
{series.map((s, si) => (
<span key={s.name} className="flex items-center gap-1.5">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: seriesColor(si) }}
/>
{s.name}
</span>
))}
</div>
)}
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={ariaLabel} className="w-full h-auto">
<line
x1={padX}
y1={H - padBottom}
x2={W - padX}
y2={H - padBottom}
stroke="var(--ce-grid)"
strokeWidth={1}
/>
{yearTicks.map((tick) => (
<text
key={tick.i}
x={px(tick.i)}
y={H - padBottom + 18}
textAnchor={tick.i === 0 ? "start" : "middle"}
fontSize={11}
fill="var(--ce-text2)"
>
{tick.text}
</text>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
))}
{series.map((s, si) => {
const pts = s.values.map((v, i) => `${px(i).toFixed(1)},${py(v).toFixed(1)}`);
const peakIdx = s.values.indexOf(Math.max(...s.values));
// labels and every series' values must be the same length; bound by
// both so a mismatched caller cannot place marks off-axis
const lastIdx = Math.min(s.values.length, n) - 1;
// Selective labels by priority (last > peak > first), dropping any
// candidate within the collision window of an already-kept label.
const labelIdxs = [lastIdx, peakIdx, 0]
.reduce<number[]>((kept, i) => {
if (i < 0 || kept.includes(i)) return kept;
if (kept.some((j) => Math.abs(i - j) <= 2)) return kept;
return [...kept, i];
}, [])
.sort((a, b) => a - b);
return (
<g key={s.name} aria-hidden="true">
{series.length === 1 && (
<path
d={`M${pts[0]} L${pts.join(" L")} L${px(lastIdx).toFixed(1)},${H - padBottom} L${px(0).toFixed(1)},${H - padBottom} Z`}
fill={seriesColor(si)}
opacity={0.1}
/>
)}
<polyline
points={pts.join(" ")}
fill="none"
stroke={seriesColor(si)}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* end dot with surface ring */}
<circle
cx={px(lastIdx)}
cy={py(s.values[lastIdx])}
r={4}
fill={seriesColor(si)}
stroke="var(--ce-surface)"
strokeWidth={2}
/>
{labelIdxs.map((i) => (
<text
key={i}
x={px(i)}
y={py(s.values[i]) - 8 - (series.length > 1 && si === 1 ? 12 : 0)}
textAnchor={i === 0 ? "start" : i === lastIdx ? "end" : "middle"}
fontSize={11}
fill="var(--ce-text2)"
>
{valueFormatter(s.values[i])}
</text>
))}
</g>
);
})}
Expand Down
Loading
Loading