A LEGO baseplate
Fixed scenes nested by stud: the LEGO catalogue (a snapshot of picabase’s, 17,592 building sets) packed onto one baseplate. Each theme is a plate, as big as its bricks need; zoom in and the plate fades as its sub-themes’ plates and its sets come in, each set a brick sized by its part count, laid like masonry with the small ones filling the gaps the big ones leave. Every scene’s bounds is its theme’s plate in the same studs, so the baseplate’s grid step is reset per scene (onEnter) and its studs stay in register with the bricks’. Custom plate and brick renderers draw each footprint once into a shared GraphicsContext, and a plate’s studs are one rectangle filled with a repeating stud texture.
The frame below runs the example built from exactly these files. Start with source.ts, then
baseplate.ts for the stud layout. Every example shares one small page shell
(packages/core/examples/shared/ExampleShell.tsx): it keeps the focus in the URL hash, builds a
breadcrumb from onSceneChange, and shows the selected item from onNodeSelect.
import { createSceneSource, type ExplorerCluster, type ExplorerNode, type ExplorerRect, type ExplorerScene, type RampColours, type CachedSceneSource,} from "@data-xplorer/core";import { LAYOUT } from "./baseplate";import { CATALOGUE, LEGO_COLOURS, paletteColour, themeById, type LegoSet, type Theme } from "./data";
/** * The catalogue as one giant baseplate, in fixed scenes. A theme is a plate; zoom into it and what * is on it comes in as the plate fades away (the package's own reveal, for a solid item): its * sub-themes' plates and its own sets, as bricks. Every scene's * `bounds` is its theme's plate, in the same studs as the scene above, so a stud is the same size * on screen either side of the swap and nothing moves when it happens. * * The focus is a theme's id (`null`, the whole catalogue). */type Focus = string;
/** What a brick or plate carries for its renderer: its size in studs, since the renderer is given * only world units, and those change from scene to scene. */export interface LegoItemData { studs: { w: number; h: number };}
/** How the bricks are coloured: `mixed` like a tub of bricks, `theme` in their theme's colour, * `era` on a ramp from the oldest set to the newest. */export type BrickColouring = "mixed" | "theme" | "era";
/** Old sets in the primary colours of the 1950s to 1970s, new ones in today's mid tones. */export const ERA_RAMP: RampColours = { stops: [LEGO_COLOURS.red, LEGO_COLOURS.yellow, LEGO_COLOURS.blue, LEGO_COLOURS.mediumAzure, LEGO_COLOURS.magenta], interpolation: "oklab",};const FIRST_YEAR = 1949;const LAST_YEAR = 2026;
const studsOf = (box: ExplorerRect): LegoItemData => ({ studs: { w: box.width, h: box.height } });
const themeHref = (theme: Theme) => `#${theme.id}`;
function themePlate(theme: Theme): ExplorerCluster<Focus, LegoItemData> { const box = LAYOUT.themeBox.get(theme)!; return { id: theme.id, label: theme.name, sublabel: `${theme.total} ${theme.total === 1 ? "set" : "sets"}`, weight: theme.total, marks: [ `${theme.total} sets`, ...(theme.children.length > 0 ? [`${theme.children.length} sub-themes`] : []), `${box.width} × ${box.height} studs`, ], href: themeHref(theme), drillsInto: theme.id, shape: "region", kind: "plate", color: theme.colour, box, data: studsOf(box), };}
function setBrick(set: LegoSet, colouring: BrickColouring): ExplorerNode<Focus, LegoItemData> { const box = LAYOUT.setBox.get(set)!; return { id: set.id, label: set.name, sublabel: [set.number, set.year].filter(Boolean).join(" · "), weight: Math.max(set.parts, 1), marks: [ `${set.parts} pieces`, ...(set.year ? [`released ${set.year}`] : []), `a ${Math.min(box.width, box.height)} × ${Math.max(box.width, box.height)} brick`, ], href: `https://rebrickable.com/search/?q=${encodeURIComponent(set.number)}`, drillsInto: null, shape: "region", kind: "brick", color: colouring === "mixed" ? paletteColour(set.number + set.name) : colouring === "theme" ? set.theme.colour : null, ramp: colouring === "era" && set.year ? (set.year - FIRST_YEAR) / (LAST_YEAR - FIRST_YEAR) : null, box, data: studsOf(box), };}
export function sceneFor(focus: Focus | null, colouring: BrickColouring): ExplorerScene<Focus, LegoItemData> { const theme = focus === null ? null : themeById(focus); if (theme === null) { return { id: "catalogue", heading: "The catalogue", layout: "fixed", bounds: LAYOUT.baseplate, clusters: CATALOGUE.roots.map(themePlate), nodes: [], edges: [], }; } return { id: theme.id, heading: theme.name, layout: "fixed", bounds: LAYOUT.themeBox.get(theme)!, clusters: theme.children.map(themePlate), nodes: theme.sets.map((set) => setBrick(set, colouring)), edges: [], };}
/** World units per stud in a scene: the package scales a fixed scene so its longer side is 1000. */export function stepFor(scene: Pick<ExplorerScene, "bounds">): number { const bounds = scene.bounds ?? LAYOUT.baseplate; return 1000 / Math.max(bounds.width, bounds.height);}
export function legoSource(colouring: () => BrickColouring): CachedSceneSource<Focus, LegoItemData> { return createSceneSource<Focus, LegoItemData>({ load: async (focus) => sceneFor(focus, colouring()), parentOf: (focus) => ({ kind: "anchored", focus: themeById(focus)?.parent?.id ?? null, anchorId: focus }), });}
export const decodeTheme = (hash: string) => (themeById(hash) ? hash : null);import type { ExplorerRect } from "@data-xplorer/core";import { CATALOGUE, type LegoSet, type Theme } from "./data";
/** * Where everything sits on the one giant baseplate, in studs. Nothing here knows about the * explorer beyond `ExplorerRect`: it is a stud layout, worked out once, bottom up. * * - A set is a brick whose footprint grows with its part count (`brickFor`). * - A theme is a plate just big enough for its bricks and its sub-themes' plates. The bricks are * laid like masonry, butted up against each other, turned whichever way fits, and dropped into * the gaps the bigger ones leave; each sub-theme's plate keeps a stud of clearance all round, and * so does the plate's own edge. * - The root themes are packed the same way onto the baseplate, rounded up to whole 32 × 32 plates. * * Every box is in the same units (absolute studs), so a theme's scene can use its plate's box as * its `bounds` and its bricks land exactly where the plate was drawn, at the same stud size. */
/** A footprint in studs, long side first, by part count. Real brick sizes, up to a big plate. */const BRICKS: { below: number; long: number; short: number }[] = [ { below: 20, long: 1, short: 1 }, { below: 50, long: 2, short: 1 }, { below: 100, long: 2, short: 2 }, { below: 200, long: 3, short: 2 }, { below: 400, long: 4, short: 2 }, { below: 800, long: 6, short: 2 }, { below: 1500, long: 8, short: 2 }, { below: 3000, long: 6, short: 4 }, { below: Infinity, long: 8, short: 6 },];
/** A set's brick, long side across: the packer may turn it. */export function brickFor(set: Pick<LegoSet, "parts">): { w: number; h: number } { const { long, short } = BRICKS.find((brick) => set.parts < brick.below)!; return { w: long, h: short };}
export interface PackItem<T> { item: T; w: number; h: number; /** Studs kept clear all round it; 0 lets neighbours butt up against it. */ margin?: number; /** Whether it may be turned a quarter, to fit better. */ turn?: boolean;}
export interface Packed<T> { /** Where each item went, and its footprint as placed (turned, if it was). */ placed: { item: T; x: number; y: number; w: number; h: number }[]; width: number; height: number;}
/** * Rectangles packed onto a stud grid, `pad` studs in from the edge: an occupancy grid, filled * biggest first, each item in the first free spot in reading order (top row first, then left to * right) where its whole footprint, margin included, is clear. A gap left round a big item is * still free, so a smaller one that fits it goes there before anything is added further down. * An item that may `turn` takes whichever way round comes first. The width is chosen from the * total area so the result comes out about `aspect` wide to high. */export function pack<T>(items: readonly PackItem<T>[], pad: number, aspect = 1.5): Packed<T> { if (items.length === 0) return { placed: [], width: 2 * pad, height: 2 * pad }; const area = items.reduce((sum, { w, h, margin = 0 }) => sum + (w + 2 * margin) * (h + 2 * margin), 0); const widest = Math.max(...items.map(({ w, h, turn, margin = 0 }) => (turn ? Math.min(w, h) : w) + 2 * margin)); const columns = Math.max(widest, Math.ceil(Math.sqrt(area * aspect))); const order = items .map((entry, i) => ({ ...entry, i })) .sort((a, b) => b.w * b.h - a.w * a.h || Math.max(b.w, b.h) - Math.max(a.w, a.h) || a.i - b.i);
// One row of cells per stud down, added as the packing grows; 1 is taken. const rows: Uint8Array[] = []; const row = (y: number) => { while (rows.length <= y) rows.push(new Uint8Array(columns)); return rows[y]!; }; /** Rows above this are full: no search starts higher. */ let firstOpen = 0;
/** The first column at or after `x` in row `y`'s span `[x, x + w)` that is taken, or -1. */ function blocked(x: number, y: number, w: number, h: number): number { for (let j = y; j < y + h; j++) { const cells = row(j); for (let i = x + w - 1; i >= x; i--) if (cells[i]) return i; } return -1; }
/** The first spot in reading order where a `w` × `h` footprint is clear. */ function firstFit(w: number, h: number): { x: number; y: number } { for (let y = firstOpen; ; y++) { for (let x = 0; x + w <= columns; ) { const taken = blocked(x, y, w, h); if (taken < 0) return { x, y }; x = taken + 1; // nothing starting at or before the taken cell can fit } } }
const placed: Packed<T>["placed"] = []; let width = 0; let height = 0; for (const { item, w, h, margin = 0, turn = false } of order) { const ways = turn && w !== h ? [{ w, h }, { w: h, h: w }] : [{ w, h }]; let best: { x: number; y: number; w: number; h: number } | null = null; for (const way of ways) { if (way.w + 2 * margin > columns) continue; const at = firstFit(way.w + 2 * margin, way.h + 2 * margin); if (!best || at.y < best.y || (at.y === best.y && at.x < best.x)) best = { ...at, ...way }; } const { x, y } = best!; const [rw, rh] = [best!.w + 2 * margin, best!.h + 2 * margin]; for (let j = y; j < y + rh; j++) row(j).fill(1, x, x + rw); while (firstOpen < rows.length && rows[firstOpen]!.every(Boolean)) firstOpen++; width = Math.max(width, x + rw); height = Math.max(height, y + rh); placed.push({ item, x: x + margin + pad, y: y + margin + pad, w: best!.w, h: best!.h }); } return { placed, width: width + 2 * pad, height: height + 2 * pad };}
/** Studs kept clear round a sub-theme's plate on its parent's plate, and inside every plate's edge. */const PLATE_MARGIN = 1;/** Studs round the edge of the baseplate. */const BASEPLATE_PAD = 3;/** A baseplate's side in studs: the catalogue is a whole number of them. */const BASEPLATE = 32;
type Entry = { kind: "theme"; theme: Theme } | { kind: "set"; set: LegoSet };
export interface Layout { /** The whole catalogue: a whole number of 32 × 32 baseplates. */ baseplate: ExplorerRect; themeBox: Map<Theme, ExplorerRect>; setBox: Map<LegoSet, ExplorerRect>; /** How many 32 × 32 baseplates, across and down. */ plates: { across: number; down: number };}
export function layOut(roots: readonly Theme[]): Layout { // Bottom up: each theme's packing, in its own studs, from (0, 0). const local = new Map<Theme, Packed<Entry>>(); function size(theme: Theme): { w: number; h: number } { const entries: PackItem<Entry>[] = [ ...theme.children.map((child) => ({ item: { kind: "theme", theme: child } as Entry, ...size(child), margin: PLATE_MARGIN })), ...theme.sets.map((set) => ({ item: { kind: "set", set } as Entry, ...brickFor(set), turn: true })), ]; const packed = pack(entries, PLATE_MARGIN); local.set(theme, packed); return { w: packed.width, h: packed.height }; } const top = pack( roots.map((theme) => ({ item: theme, ...size(theme), margin: PLATE_MARGIN })), BASEPLATE_PAD, ); const across = Math.ceil(top.width / BASEPLATE); const down = Math.ceil(top.height / BASEPLATE); const baseplate = { x: 0, y: 0, width: across * BASEPLATE, height: down * BASEPLATE }; // Centred on the plates, on whole studs. const dx = Math.floor((baseplate.width - top.width) / 2); const dy = Math.floor((baseplate.height - top.height) / 2);
// Top down: every local position made absolute. const themeBox = new Map<Theme, ExplorerRect>(); const setBox = new Map<LegoSet, ExplorerRect>(); function place(theme: Theme, x: number, y: number, width: number, height: number) { themeBox.set(theme, { x, y, width, height }); for (const at of local.get(theme)!.placed) { if (at.item.kind === "set") setBox.set(at.item.set, { x: x + at.x, y: y + at.y, width: at.w, height: at.h }); else place(at.item.theme, x + at.x, y + at.y, at.w, at.h); } } for (const at of top.placed) place(at.item, dx + at.x, dy + at.y, at.w, at.h); return { baseplate, themeBox, setBox, plates: { across, down } };}
export const LAYOUT = layOut(CATALOGUE.roots);// Loaded only through `renderers={() => import("./lego-renderers")}`. PixiJS is imported as types// only: the classes come from `create(kit)`.import type { Container, Graphics, GraphicsContext, Texture } from "pixi.js";import { defineRenderers, type ItemFrame, type PixiKit, type RenderNode } from "@data-xplorer/core/pixi";import type { LegoItemData } from "./source";
/** * A set as a brick seen from above, and a theme as a plate. Both are drawn in studs, centred * on (0, 0), and the view is scaled by the world units in a stud, so a stud is the same size on * every brick and on the baseplate's grid behind them. * * Drawing is in white and greys, tinted per item, apart from the shadows and the light catching * each stud, which are separate and never tinted. The geometry depends only on a footprint, so it * is drawn once per footprint into a shared `GraphicsContext` and every view of that size reuses * it: a scene of a thousand bricks has a dozen footprints. */
/** Below this many pixels a stud is a smudge: the brick is drawn flat. */const STUD_PX = 5;/** Below this, the light on each stud is left off. */const HIGHLIGHT_PX = 10;/** A stud's radius, in studs: LEGO's 4.8 mm stud on its 8 mm pitch. */const STUD_R = 0.3;/** Each brick stops short of its neighbours by this much on every side, so a seam shows. */const INSET = 0.04;
const EDGE = { color: 0x000000, alpha: 0.28 };
const studsOf = (node: RenderNode) => (node.item.data as LegoItemData | undefined)?.studs ?? { w: 1, h: 1 };
/** Pixels per stud this frame. `frame.width` is the box's width in world units. */const pxPerStud = (frame: ItemFrame, w: number) => ((frame.width / w) * frame.screenSize) / frame.size;
/** Shared contexts, by footprint and part. */const contexts = new Map<string, GraphicsContext>();/** The classes from the first `create(kit)`: `update` draws a footprint it has not met yet. */let pixi: PixiKit;
function context(key: string, draw: (g: Graphics) => void): GraphicsContext { let ctx = contexts.get(key); if (!ctx) { const g = new pixi.Graphics(); draw(g); ctx = g.context; contexts.set(key, ctx); } return ctx;}
/** The stud textures' side in pixels: one stud. */const TILE = 64;
/** One stud, drawn on a canvas, `part` of it: what the plate tints (`studs`), or the light on it. */function studCanvas(part: "studs" | "light"): HTMLCanvasElement { const canvas = document.createElement("canvas"); canvas.width = canvas.height = TILE; const g = canvas.getContext("2d")!; const [c, r] = [TILE / 2, TILE * STUD_R]; const circle = (x: number, y: number, radius: number) => { g.beginPath(); g.arc(x, y, radius, 0, Math.PI * 2); }; if (part === "studs") { circle(c + TILE * 0.04, c + TILE * 0.06, r); g.fillStyle = "rgba(0, 0, 0, 0.3)"; g.fill(); circle(c, c, r); g.fillStyle = "#fff"; g.fill(); g.lineWidth = TILE * 0.04; g.strokeStyle = "rgba(0, 0, 0, 0.28)"; g.stroke(); } else { g.beginPath(); g.arc(c, c, r - TILE * 0.06, Math.PI * 1.05, Math.PI * 1.45); g.lineWidth = TILE * 0.05; g.strokeStyle = "rgba(255, 255, 255, 0.65)"; g.stroke(); } return canvas;}
const studTextures = new Map<"studs" | "light", Texture>();function studTexture(part: "studs" | "light"): Texture { let texture = studTextures.get(part); if (!texture) { texture = pixi.Texture.from(studCanvas(part)); // Mipmaps, so a plate of small studs averages out rather than shimmering as it moves. texture.source.autoGenerateMipmaps = true; studTextures.set(part, texture); } return texture;}
/** * A `w` × `h` footprint's studs (or the light on them) on `layer`: one rectangle filled with the * one-stud texture, repeating. It is drawn in the texture's pixels from (0, 0), so the texture * tiles once per stud from the footprint's corner, then scaled down to studs. Circles drawn in * stud units came out as hexagons: PixiJS picks a circle's segments by its radius, and 0.3 is few. */function tileStuds(layer: Graphics, part: "studs" | "light", w: number, h: number) { layer.context = context(`${part}:${w}x${h}`, (g) => g.rect(0, 0, w * TILE, h * TILE).fill({ texture: studTexture(part), textureSpace: "global" })); layer.position.set(-w / 2, -h / 2); layer.scale.set(1 / TILE);}
interface BrickView extends Container { shadow: Graphics; body: Graphics; studs: Graphics; light: Graphics; footprint: string;}
const brick = { create(kit: PixiKit): BrickView { pixi = kit; const view = new kit.Container() as BrickView; view.shadow = new kit.Graphics(); view.body = new kit.Graphics(); view.studs = new kit.Graphics(); view.light = new kit.Graphics(); view.footprint = ""; view.addChild(view.shadow, view.body, view.studs, view.light); return view; }, update(view: BrickView, node: RenderNode, frame: ItemFrame) { const { w, h } = studsOf(node); const key = `${w}x${h}`; if (view.footprint !== key) { view.footprint = key; view.shadow.context = context(`brick-shadow:${key}`, (g) => g.roundRect(-w / 2 + INSET + 0.1, -h / 2 + INSET + 0.14, w - 2 * INSET, h - 2 * INSET, 0.1).fill({ color: 0x000000, alpha: 0.4 }), ); view.body.context = context(`brick-body:${key}`, (g) => g.roundRect(-w / 2 + INSET, -h / 2 + INSET, w - 2 * INSET, h - 2 * INSET, 0.08).fill(0xffffff).stroke({ width: 0.05, ...EDGE }), ); tileStuds(view.studs, "studs", w, h); tileStuds(view.light, "light", w, h); } view.scale.set(frame.width / w); const tint = frame.tint ?? 0xa0a5a9; view.body.tint = tint; view.studs.tint = tint; const px = pxPerStud(frame, w); view.studs.visible = px >= STUD_PX; view.light.visible = px >= HIGHLIGHT_PX; view.shadow.visible = px >= STUD_PX / 2; }, destroy: (view: BrickView) => view.destroy({ children: true }),};
interface PlateView extends Container { shadow: Graphics; face: Graphics; /** The studs: one rectangle filled with the stud texture, tiled one per stud. Tinted. */ studs: Graphics; /** The light on each stud, tiled the same way. Never tinted. */ light: Graphics; rim: Graphics; footprint: string;}
/** * A theme is a plate: a face in its colour, with its studs on top (`tileStuds`, so even Duplo's * 7,000 studs are one rectangle). * * Zoom into a plate and the package fades it out as its contents fade in (a solid item is washed * out completely), so the plate gives way to what is on it, on the same studs. */const plate = { create(kit: PixiKit): PlateView { pixi = kit; const view = new kit.Container() as PlateView; view.shadow = new kit.Graphics(); view.face = new kit.Graphics(); view.studs = new kit.Graphics(); view.light = new kit.Graphics(); view.rim = new kit.Graphics(); view.footprint = ""; view.addChild(view.shadow, view.face, view.studs, view.light, view.rim); return view; }, update(view: PlateView, node: RenderNode, frame: ItemFrame) { const { w, h } = studsOf(node); const key = `${w}x${h}`; if (view.footprint !== key) { view.footprint = key; const [l, t] = [-w / 2, -h / 2]; const r = Math.min(0.3, w / 6, h / 6); view.shadow.context = context(`plate-shadow:${key}`, (g) => g.roundRect(l + 0.1, t + 0.14, w, h, r).fill({ color: 0x000000, alpha: 0.35 })); view.face.context = context(`plate-face:${key}`, (g) => g.roundRect(l, t, w, h, r).fill(0xffffff)); tileStuds(view.studs, "studs", w, h); tileStuds(view.light, "light", w, h); view.rim.context = context(`plate-rim:${key}`, (g) => g.roundRect(l, t, w, h, r).stroke({ width: 0.06, ...EDGE })); } view.scale.set(frame.width / w); const tint = frame.tint ?? 0xa0a5a9; view.face.tint = tint; view.studs.tint = tint; // The studs come in as they grow big enough to read, rather than all at once. const px = pxPerStud(frame, w); view.studs.alpha = Math.min(1, Math.max(0, (px - STUD_PX + 1) / 3)); view.studs.visible = view.studs.alpha > 0; view.light.visible = px >= HIGHLIGHT_PX; view.shadow.visible = px >= STUD_PX / 2; }, destroy: (view: PlateView) => view.destroy({ children: true }),};
export default defineRenderers({ brick, plate });import { useState } from "react";import { createRoot } from "react-dom/client";import { FALLBACK_PALETTE, type ExplorerPalette } from "@data-xplorer/core";import { ExampleShell } from "../shared/ExampleShell";import { LAYOUT } from "./baseplate";import { ancestry, CATALOGUE, themeById } from "./data";import { decodeTheme, ERA_RAMP, legoSource, sceneFor, stepFor, type BrickColouring } from "./source";
/** LEGO's green baseplate, and a lighter green for its studs' highlights (the grid tile is drawn * in greys, so this is the colour its white parts take). */const BASEPLATE_GREEN = "#237841";const STUD_GREEN = "#3fae63";
const PALETTE: ExplorerPalette = { ...FALLBACK_PALETTE, background: BASEPLATE_GREEN, label: 0xffffff, labelStroke: 0x05131d, hover: 0xffffff, selection: 0xf2cd37,};
const COLOURINGS: BrickColouring[] = ["mixed", "theme", "era"];
let colouring: BrickColouring = "mixed";const source = legoSource(() => colouring);const BASEPLATE_TILE = () => import("./baseplate-tile");
function Lego() { const [colourIndex, setColourIndex] = useState(0); // One stud of the scene on screen: a fixed scene's world is scaled to its own bounds, so the // grid's step follows the scene, and the baseplate's studs line up with the bricks' studs. const [step, setStep] = useState(() => stepFor(sceneFor(decodeTheme(decodeURIComponent(location.hash.slice(1))), colouring)));
function nextColouring() { const next = (colourIndex + 1) % COLOURINGS.length; colouring = COLOURINGS[next]!; setColourIndex(next); source.invalidate(); }
return ( <ExampleShell title="A LEGO baseplate" intro={ <p> The LEGO catalogue ({CATALOGUE.roots.length} themes, {LAYOUT.setBox.size.toLocaleString()} building sets) as one baseplate of {LAYOUT.plates.across} × {LAYOUT.plates.down} plates. Each theme is a plate, as big as the bricks on it need. Zoom in and the plate fades as what is on it comes in: its sub-themes' plates and its sets, each a brick sized by its part count, laid like masonry, on the same studs. Every scene is{" "} <code>layout: "fixed"</code>, with its theme's plate as its <code>bounds</code>. </p> } source={source} encode={(id) => id} decode={decodeTheme} trailOf={(id) => ["Catalogue", ...ancestry(id === null ? null : themeById(id)).map((theme) => theme.name)]} palette={PALETTE} ramp={ERA_RAMP} background={{ color: BASEPLATE_GREEN, grid: { tile: BASEPLATE_TILE, step, zoom: "continuous", alpha: 1, color: STUD_GREEN, minScreenStep: 6 }, }} renderers={() => import("./lego-renderers")} customViews={{ max: 2500 }} events={{ onEnter: ({ scene }) => setStep(stepFor(scene)) }} toolbar={ <button type="button" onClick={nextColouring}> Bricks: {COLOURINGS[colourIndex]} </button> } /> );}
createRoot(document.getElementById("root")!).render(<Lego />);