20 — The 3D landscape and the PDF button
Read this first: this chapter reads the two most fragile features on the analytics page — a WebGL scene and a screenshot-to-PDF exporter — and discovers that nearly every line in them is a scar from a specific failure. You will find a lazy-load comment that names its own bundle cost, a regression written up inside the file that regressed, and a one-word GL flag whose absence turns a PDF black.
Time: about 35 minutes. Assumes lesson 19.
Three.js pays rent per route
AnalyticsScene.tsx is the host component. Its first real line is not a component — it is a
budget decision, and the comment states the price:
// Code-split so three.js (~150kB) only enters the bundle once this panel is
// actually rendered on the analytics page — never on any other route.
const DepartmentLandscape = lazy(() => import('./DepartmentLandscape'));
Every other import in this file is cheap. DepartmentLandscape is the only module that pulls
three and @react-three/fiber, so it is the only one behind React.lazy. An employee opening
the payslips page never downloads a 3D engine to render a table.
One fallback, three jobs
The host takes the 2D twin — the RankBars chart from lesson 16 —
as a prop, and the component doc says exactly what that twin is for:
/**
* Hosts the 3D department landscape. Degrades to the 2D bar twin under
* prefers-reduced-motion or the user's 2D toggle — identical data, zero info loss.
* The exact numbers always live in the caption row + tooltip beside the scene.
*/
One ReactNode serves three roles, and the code paths make each one visible:
const use3d = !reduce && !forceFallback;
/* … */
if (!use3d) return <>{fallback}</>;
<Suspense fallback={<Box position="absolute" inset={0}>{fallback}</Box>}>
<DepartmentLandscape data={data} selected={selected} palette={palette} onSelect={onSelect} onHover={setHovered} />
</Suspense>
- Loading state. While the lazy chunk downloads,
Suspenseshows the 2D chart — not a spinner. The user gets real numbers immediately; the 3D view is an upgrade that arrives. - Reduced motion.
useReducedMotion()returning true means the canvas never mounts at all — not a paused canvas, no canvas. A rotating turntable is exactly what that OS setting asks to avoid. - The 2D toggle.
forceFallbackis the user's own preference from the panel toggle, and it takes the identical early return.
The rule: the degraded view is the same component in all three cases, so it cannot drift. If the 2D twin gains a column, loading, reduced-motion, and toggled users all gain it in one edit.
The in-view gate that broke the toggle
An earlier version added a fourth condition: only mount the canvas while the panel is scrolled into view. It sounds like a pure win. The comment that replaced it is the incident report:
// Mount the Canvas whenever we're in 3D — no in-view gating. (The earlier
// useInView gate broke the 2D→3D toggle: its observed element unmounted in 2D
// and never re-attached, so `near` stuck false and the scene never came back.
// React.lazy already keeps three.js out of the base bundle on its own.)
Trace the failure: useInView observes a DOM element. Toggle to 2D and the early return unmounts
that element. The observer now watches nothing, so its state freezes at false — and toggling back
to 3D checks a flag that can never become true again. The scene is gone until a page reload.
The last sentence of the comment is the design lesson: the gate was solving a problem
React.lazy had already solved. Defenses that duplicate an existing defense are not free — they
add failure modes without adding protection.
A number never lives only in 3D
The canvas container is a picture to assistive technology, and it says where the real data is:
<Box position="relative" h={`${height}px`} role="img" aria-label="3D department headcount landscape. A data table is available in the 2D view.">
And the caption row under the scene renders every exact figure in plain text, always:
{data.map((d) => (
<Text
key={d.name}
/* … */
onClick={() => onSelect(d.name)}
>
{d.name} <Box as="span" color="fg.muted" fontVariantNumeric="tabular-nums">{d.headcount}</Box>
</Text>
))}
The scene component's own doc states the principle from the other side: "the precise number always lives in the 2D bar/labels beside it, so no magnitude is read off perspective alone." Perspective foreshortening makes a far block look shorter than a near one of equal height — a 3D view is allowed to give shape, never the only copy of a number.
Blocks, lifts, and a turntable that knows when to stop
DepartmentLandscape.tsx maps each department to one rounded block. Height and color both come
from headcount:
const height = MIN_H + (d.headcount / maxHc) * (MAX_H - MIN_H);
const color = sequentialColor(d.headcount, maxHc, palette.sequential);
sequentialColor is the magnitude ramp from lesson 15 — departments
are being ranked by size, so the color must be sequential, not categorical. The MIN_H floor
matters too: a one-person department still renders a visible, clickable block instead of a sliver.
Hover feedback is a lift, animated by hand in useFrame:
useFrame(() => {
if (!liftGroup.current) return;
const target = hover || selected ? 0.22 : 0;
lift.current += (target - lift.current) * 0.15;
liftGroup.current.position.y = height / 2 + lift.current;
});
Each frame moves 15% of the remaining distance — an exponential ease-out with no animation library, no timeline, and no state updates: it writes to a ref and mutates the three.js object directly, so React never re-renders during the animation.
The whole scene sits inside a slow turntable, and the turntable is polite:
/** Slow turntable that pauses whenever the user is inspecting a block. */
function Rig({ paused, children }: { paused: boolean; children: React.ReactNode }) {
const ref = useRef<Group>(null);
useFrame((_, delta) => {
if (ref.current && !paused) ref.current.rotation.y += delta * 0.12;
});
return <group ref={ref}>{children}</group>;
}
It mounts as <Rig paused={hovering || selected != null}>. Try to click a moving target and you
understand the condition: rotation while you hover means the block slides out from under your
cursor; rotation while a department is selected means the thing you asked about drifts away.
Clicking a block calls onSelect(datum.name), which is the same cross-filter write you traced in
lesson 19 — the 3D scene is just another input to ?department=.
Two more defensive details live in the pointer handlers. The cursor is set through the DOM —
document.body.style.cursor = 'pointer' on over, 'auto' on out — because a WebGL canvas has no
per-element CSS cursor; there is one canvas element, so the DOM is the only place to say
"this is clickable". And the Canvas itself carries onPointerMissed={() => props.onHover(null)},
which clears the hover tooltip when the pointer leaves every block — without it, a fast mouse exit
can skip onPointerOut and leave a ghost tooltip pinned to the corner.
A bevel is cheaper than a dependency
The block is not a raw <boxGeometry>. It is a local RoundedBox, and its doc comment records
two decisions at once — one aesthetic, one about dependency cost:
/**
* Soft-edged box. Hard-edged primitives read as "programmer 3D"; the bevel is
* what makes the product scenes look designed. Replaces drei's <RoundedBox>,
* which is not worth pulling the whole drei dependency in for.
*/
drei is the standard grab-bag of R3F helpers, and this repo uses exactly one of them. So the
component wraps RoundedBoxGeometry from three/addons — code that ships inside the three.js
package already paid for — in a useMemo, and drei stays out of the bundle. The same
per-kilobyte discipline as the lazy import, applied to a transitive dependency.
The buffer WebGL throws away
Here is the Canvas mount, with the one GL flag this lesson is named after:
<Canvas
camera={{ position: [0, 3.4, 8.5], fov: 42 }}
dpr={[1, 2]}
// preserveDrawingBuffer lets html2canvas capture the WebGL canvas for PDF export.
gl={{ antialias: true, alpha: true, preserveDrawingBuffer: true }}
style={{ position: 'absolute', inset: 0 }}
onPointerMissed={() => props.onHover(null)}
>
Predict: delete preserveDrawingBuffer: true, open the Overview tab with the 3D panel
visible, and click Export PDF. What does the PDF show in the rectangle where the canvas was? Write
your answer down before reading on.
Resolved: a blank (typically black) rectangle. By default, WebGL is allowed to clear the drawing
buffer as soon as a frame is composited to the screen — the pixels exist on your monitor but not
in the canvas anymore. html2canvas reads the canvas after the fact, milliseconds later, and
gets the cleared buffer. preserveDrawingBuffer: true tells the GL context to keep the last frame
readable. It disables a swap optimization, which is why it is off by default — an acceptable price
for one small scene that must survive a screenshot.
One screenshot, sliced into pages
The Export PDF button does not know anything about charts. It calls renderNodeToPdfBlob from
payslipPdf.ts — the same pipeline that renders payslips and BIR government forms:
const canvas = await html2canvas(node, { scale: 2, backgroundColor: '#ffffff', onclone: options?.onclone });
scale: 2 renders the DOM at double resolution so text stays crisp in print. The canvas then
becomes one tall image, and the encoding choice carries a measurement:
// JPEG instead of PNG: the payslip is a flat, mostly-solid-color document, so PNG's
// lossless encoding costs a lot of size (~7MB/page at scale 2) for no visible benefit.
// JPEG at high quality is visually identical here and roughly an order of magnitude
// smaller, which matters a lot for bulk export (memory pressure across many pages,
// and the final zip size).
const imgData = canvas.toDataURL('image/jpeg', 0.92);
The tall image is then walked across A4 pages by drawing the same image on every page at an increasingly negative offset — a sliding window, not actual slicing:
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
The blob then goes through triggerBlobDownload — a created <a download>, a click, and a
URL.revokeObjectURL. The file also exports renderPagedNodeToPdfBlob, which captures each
[data-pdf-page] node separately instead — its doc comment explains that fixed-interval slicing
"can cut a table row in half right at the page boundary", which is exactly what multi-page
government forms cannot tolerate. Analytics accepts the simple slicer; a chart cut mid-gradient is
ugly, a BIR form cut mid-row is wrong.
The clone that strips its shadows
The analytics page adds one thing to the shared pipeline, and it is another incident preserved as
a comment in HrAnalytics.tsx:
const blob = await renderNodeToPdfBlob(contentRef.current, 'portrait', {
// Chakra's shadow tokens serialize as CSS `color(srgb …)`, which
// html2canvas can't parse — strip shadows in the clone so capture succeeds.
onclone: (doc) => {
const style = doc.createElement('style');
style.textContent = '*{box-shadow:none !important}';
doc.head.appendChild(style);
},
});
html2canvas does not screenshot your screen — it re-parses computed CSS and repaints it onto a
canvas, and its parser predates the modern color(srgb …) function that Chakra's semantic shadow
tokens resolve to. The fix uses the escape hatch built for exactly this: onclone runs against the
cloned document that html2canvas paints from, so the export loses its drop shadows while the page
on screen keeps them. Nothing visible to the user changes.
One last property of this export falls out of a decision made two lessons ago. contentRef wraps
the Tabs.Root that lesson 14 configured with lazyMount unmountOnExit — so inactive tabs are not hidden, they are absent from the DOM. The capture
therefore contains exactly the active tab. The export is what you see, by construction, not by a
filter someone has to maintain.
Where this shows up in MotorPH
- frontend/src/components/analytics/three/AnalyticsScene.tsx — the lazy host, the three-role fallback, the in-view postmortem.
- frontend/src/components/analytics/three/DepartmentLandscape.tsx — blocks, rig, and
preserveDrawingBuffer. - frontend/src/components/marketing/three/RoundedBox.tsx — the drei-avoidance bevel, shared with the marketing scenes.
- frontend/src/components/payroll/payslipPdf.ts — the shared capture pipeline; payslips and every government-form dialog call it too.
- frontend/src/pages/hr/HrAnalytics.tsx — the Export button and the shadow-stripping
onclone. - frontend/src/components/analytics/vizTokens.ts —
sequentialColor, the magnitude ramp the blocks are painted with.
Recap
- Lazy-load the engine, and only the engine —
React.lazykeeps three.js (~150kB) off every other route, and the in-view regression proved that adding a second gate on top of it breaks more than it saves. - One fallback node plays loading, reduced-motion, and 2D-toggle — a single 2D twin cannot
drift out of sync with itself, and
useReducedMotionmeans no canvas, not a paused one. - 3D gives shape, never the only copy of a number —
role="img"plus an always-rendered caption row, because perspective distorts magnitude. preserveDrawingBuffer: trueor the export is black — WebGL clears its buffer after present, and html2canvas reads the canvas after the fact.- html2canvas repaints CSS, it does not screenshot — so JPEG-vs-PNG is a measured 10× size
choice,
onclonestrips thecolor(srgb …)shadows its parser chokes on, andunmountOnExitmakes the capture exactly the active tab.