17 — The chart wrappers, and the Recharts scars
Read this first: you will read all five files in frontend/src/components/analytics/charts/ —
345 lines including the shared types — and discover that nearly every comment in them documents
something Recharts once did wrong. The wrappers are not abstraction for elegance's sake; they are
scar tissue, and this chapter reads the scars one by one.
Time: about 35 minutes. Assumes lesson 16.
Five wrappers, one contract
The folder holds one wrapper per chart shape, and each doc comment is a design rule from lesson 15 enforced at the only place a chart can be instantiated:
TrendLine— "Multi-series line for monthly trends. Single-axis only — never dual-scale."TrendBars— "Grouped or stacked monthly bars. Stacking is driven by per-series stackId."RankBars— "Ranked horizontal bars (departments, leave types). Nominal categories share one identity hue — never colored by value, since bar length already shows magnitude."StatusDonut— "Donut for a categorical breakdown, with a legend and a center total."DistributionBars— "Ordinal band distribution (age, tenure, salary). Bands are ordered, so color runs a single-hue sequential ramp low→high."
All five share one contract, small enough to quote whole:
/** One plotted series inside a multi-series bar/line chart. */
export interface SeriesDef {
key: string;
name: string;
color: string;
/** Shared stackId groups series into one stacked bar. */
stackId?: string;
}
export type ValueFormat = (value: number, name: string) => string;
That is the entire types.ts. A page describes what to plot — which field, what to call it,
which palette hue — and the wrapper owns how. Here is a real consumer, from the attendance tab:
<TrendBars
data={series}
series={[
{ key: 'presentDays', name: 'Present', color: colors.categorical[2], stackId: 'a' },
{ key: 'onLeaveDays', name: 'On leave', color: colors.categorical[6], stackId: 'a' },
{ key: 'absentDays', name: 'Absent', color: colors.categorical[7], stackId: 'a' },
]}
onPointClick={openMonth}
/>
Grep the five analytics tab pages for from 'recharts' and you get nothing — the wrappers are the
only door. Even stacking is not a mode prop; the wrapper infers it from the data description:
const isStacked = (series: SeriesDef[]) => series.some((s) => s.stackId);
Inside, every wrapper spreads the same theme fragments ({...t.grid}, {...t.axis} from
useChartTheme, whose file comment says heights and bar caps live there once "so no chart
re-invents a magic number") and hands hover rendering to lesson 16's
VizTooltip. Five shapes, one system. Now for what the wrappers are protecting you from.
Scar one: the click that is an index, not a row
Every trend chart drills on click — that is how lesson 18's drawer
opens. The handler in TrendLine (and, verbatim, in TrendBars) carries the first scar as a
comment:
<LineChart
data={data}
margin={{ top: 8, right: 12, left: -12, bottom: 0 }}
style={onPointClick ? { cursor: 'pointer' } : undefined}
// Recharts v3 click state carries activeIndex (not activePayload), so
// resolve the clicked row by indexing back into `data`.
onClick={onPointClick ? (s) => {
const idx = Number((s as { activeIndex?: number | string | null })?.activeIndex);
if (Number.isInteger(idx) && idx >= 0 && data[idx]) onPointClick(data[idx] as Record<string, unknown>);
} : undefined}
>
Every Recharts example you find reads activePayload off the chart-level click state. In v3 that
field is not there — you get activeIndex, and its type annotation in the cast is the rest of the
story: it can be a number, a numeric string, or null. Hence the gauntlet: Number(...) coerces,
Number.isInteger rejects the NaN that null becomes, >= 0 rejects sentinel negatives, and
data[idx] guards against an index that outlived a data swap. What survives is the page's own row
object, handed back untouched — openMonth receives exactly the element it put into data, so
drill-building code never parses Recharts internals.
Why a chart-level handler at all? Look at what there is to hit: TrendLine renders dot={false}
with a 4-pixel activeDot, and grouped monthly bars run a few pixels wide. Chart-level click makes
the whole category band the target. The big-mark charts do the opposite — RankBars puts onClick
on the Bar itself, where the entry carries its row directly:
onClick={clickable ? (entry) => {
const payload = (entry as { payload?: Record<string, unknown> })?.payload;
if (!payload) return;
if (onRowClick) onRowClick(payload);
else if (payload[categoryKey] != null) onSelect?.(String(payload[categoryKey]));
} : undefined}
StatusDonut and DistributionBars follow the same per-mark pattern. The rule to keep:
chart-level clicks give you an index to resolve; per-mark clicks give you entry.payload.
Mixing them up compiles fine and drills on undefined.
Note also style={onPointClick ? { cursor: 'pointer' } : undefined} — the same honesty rule as the
KPI tile in lesson 16: no handler, no pointer, no false promise.
Scar two: animation is off everywhere, for two different reasons
Open any of the five files and you find isAnimationActive={false} on every Line, Bar, and
Pie. Uniformly. Recharts animates by default, and entrance animations are the cheapest way to
make a dashboard feel alive — so this took two separate incidents to earn.
Predict: you delete every isAnimationActive={false} and reload the analytics page. Which
charts vanish, and on which user action? Write your answer down before reading on.
The resolution is written next to two of the flags. In TrendBars:
// Off to avoid the clip-animation-vanish when a tab first reveals the chart.
isAnimationActive={false}
And in RankBars, a different failure with the same cure:
// The horizontal-bar clip animation gets stuck at width 0 when the
// ResponsiveContainer starts hidden and resizes — off so bars always paint.
isAnimationActive={false}
Two distinct mechanisms. The entrance animation is a one-shot clip that plays on mount — and the analytics page (lesson 14) is tabbed, so most charts mount while their tab is not the visible one. The animation runs against a container with no real size, finishes with the marks clipped to nothing, and never runs again. So the answer: every chart on a tab you have not visited yet, and the triggering action is switching to that tab for the first time. You get axes, gridlines, a legend — and no data. An empty-looking chart over a healthy query: the silent-zero bug from lesson 16, reborn in chart form.
The RankBars variant is nastier because the chart stays broken: a horizontal bar animates its
width, and when the ResponsiveContainer first measures at zero and then resizes, the animation
latches at width 0. The bars exist — tooltips fire on hover — they just have no pixels.
The overview tab's charts, mounted visible, animate perfectly. That asymmetry is why the flag is on
every mark in all five files, including TrendLine and StatusDonut where no comment justifies
it locally: the team chose one uniform rule over remembering which charts are tab-safe. When you add
a sixth wrapper, copy the flag.
Scar three: the axis you may blank but not delete
RankBars lays its bars horizontally (layout="vertical" in Recharts terms — the layout names the
category axis). The value axis is therefore the X axis, and it shows nothing, because a LabelList
already prints each value at the bar's end. The obvious move is to delete it, or pass hide. The
comment above it says why you must not:
{/* Keep the value axis present (not `hide`) — a hidden axis collapses the
plot geometry in a vertical-layout bar chart — but render it invisibly,
since the LabelList already shows each value. */}
<XAxis type="number" allowDecimals={false} tick={false} axisLine={false} tickLine={false} height={1} />
Recharts derives the plot rectangle from the axes it lays out; take the value axis out of a
vertical-layout chart and the geometry collapses — bars with nowhere to be. The escape hatch is to
keep the axis in the layout while painting nothing: tick={false}, axisLine={false},
tickLine={false}, height={1}. One pixel of layout, zero ink.
The container height is data-driven too, from chartTheme.ts:
/** Vertical (horizontal-bar) chart height that grows with the category count. */
export const rankChartHeight = (rows: number) => Math.max(CHART_H.sm, rows * 36 + 48);
Twelve departments get twelve rows of room instead of squashing into a fixed 220 pixels — which is why ranked panels on the page are different heights and none of them look cramped.
Scar four: the label Recharts cannot center
StatusDonut puts the total in the donut hole. Recharts has a feature for exactly this — a Label
child of the Pie. The wrapper does not use it, and says why:
{/* Center total overlaid as HTML — more robust than a Recharts <Label>,
which mis-positions inside a Pie in this version. */}
<VStack position="absolute" inset={0} justify="center" align="center" gap={0} pointerEvents="none">
<Text fontSize="2xl" fontWeight="700" color="fg" lineHeight="1" fontVariantNumeric="tabular-nums">
{total.toLocaleString()}
</Text>
The chart sits in a position="relative" box; the total is ordinary Chakra text absolutely
positioned over it. pointerEvents="none" is the load-bearing prop — the overlay covers the whole
chart, and without it every slice click and tooltip hover would die on a block of text. The escape
generalizes: when a Recharts primitive positions wrongly, stop fighting it inside the SVG and
overlay HTML on top. Lesson 16's sparkline made the same call for cost; this one makes it for
correctness.
The lies that read as polish
Two last tricks in TrendBars, both cosmetic, both commented:
// A surface-colored hairline gives stacked segments the 2px gap that
// keeps adjacent fills from bleeding into one another.
stroke={stacked ? t.colors.surface : undefined}
strokeWidth={stacked ? 1.5 : 0}
// Round only the top of a stack (or a standalone/grouped bar).
radius={!stacked || i === lastIdx ? [4, 4, 0, 0] : [0, 0, 0, 0]}
The "gap" between stacked segments is not geometry — it is each segment stroked in the surface
color, the card's own background, so adjacent fills read as separated without touching the data or
the tooltip math. StatusDonut plays the same trick in polar form: stroke={t.colors.surface}
with paddingAngle={2} cuts its slice separators from background paint.
The radius line fixes what uniform rounding would break: give every segment [4, 4, 0, 0] and each
one rounds its own top — pill shapes floating mid-stack. Only the last series gets the rounding,
because Recharts renders bars in declaration order and the last SeriesDef lands on top — which
also means callers list stacked series bottom-up, as the attendance tab did above. In a grouped or
single-series chart, !stacked makes every bar its own top and every bar gets the rounding.
Why the wrappers stay thin
Count what you just read: one click-state translation, two animation landmines, one geometry
collapse, one mispositioned label, two coats of paint. Each is a debugging session someone paid
for, and each now lives in exactly one file, next to the comment that explains it. That is the
design: 345 lines is small because the scars are concentrated — a page author gets rows-out
clicks and palette-in series, and cannot re-trip a mine they never see. When a Recharts upgrade
moves the click state again, the diff is two files, not every chart on every tab. The alternative
was the old way: each page importing Recharts, each page rediscovering the vanish. What happens
after onPointClick fires — the drawer, the records, the stacked detail — is the next chapter.
Where this shows up in MotorPH
- frontend/src/components/analytics/charts/types.ts — the whole contract, quoted above in full
- frontend/src/components/analytics/charts/TrendLine.tsx — chart-level click with the
activeIndexguard - frontend/src/components/analytics/charts/TrendBars.tsx — stack inference, stroke gaps, the tab-reveal comment
- frontend/src/components/analytics/charts/RankBars.tsx — the invisible value axis and the width-0 comment
- frontend/src/components/analytics/charts/StatusDonut.tsx — the HTML center total
- frontend/src/components/analytics/charts/DistributionBars.tsx — sequential ramp plus a per-mark
payloadclick - frontend/src/components/analytics/chartTheme.ts —
CHART_H,MAX_BAR,rankChartHeight, and the shared axis/grid props - frontend/src/pages/hr/hr-analytics/AttendanceTab.tsx —
SeriesDef[]call sites, no Recharts import in sight - the Frontend reference — option lists rather than story, when you need them
Recap
- Pages pass
dataplusSeriesDef[]and never import Recharts — one wrapper per chart shape carries the lesson-15 rules, so a page cannot instantiate a chart that breaks them. - Chart-level clicks carry
activeIndex; per-mark clicks carryentry.payload— coerce the index,Number.isIntegerit, and index back intodata, or you drill onundefined. isAnimationActive={false}on every mark — the entrance clip animation vanishes any chart a tab reveals late and latches horizontal bars at width 0; one uniform flag beats remembering which charts are safe.- Blank the value axis, never remove it — a vertical-layout chart's plot geometry collapses
without it, so
RankBarskeeps aheight={1}axis with every visible part turned off. - When Recharts positions it wrongly, overlay HTML; when geometry is too expensive, paint — the
donut's center total is absolutely-positioned text with
pointerEvents="none", and stacked "gaps" are surface-colored strokes.