fix(calendar-lab): rewrite tldraw zoom as LOD — render only current level shapes

Previous implementation created all ~589 shapes (year + months + weeks + days)
at once, causing visual chaos on load and broken zoom. New approach dynamically
creates/destroys shapes per level: year shows 12 months, month shows 6 weeks,
week shows 7 days, day shows time slots. Wheel prime pattern (2 ticks) prevents
accidental zooms. Double-click resets to year view.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 00:52:58 +07:00
parent 6cce211c0b
commit 1db8e58da1

View File

@@ -5,27 +5,25 @@ import { createRoot } from "react-dom/client";
import { Tldraw, createShapeId, toRichText } from "tldraw";
import "tldraw/tldraw.css";
type TldrawApi = {
Tldraw: unknown;
createShapeId: (seed?: string) => string;
toRichText: (text: string) => unknown;
};
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
type ShapeNode = {
id: string;
level: number;
parentId: string | null;
label: string;
bounds: { x: number; y: number; w: number; h: number };
};
const LEVEL_LABELS = ["year", "month", "week", "day"];
const LEVEL_LABELS = ["year", "month", "week", "day"] as const;
const MONTH_LABELS = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December",
];
const ZOOM_MS = 1100;
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const ZOOM_MS = 800;
const PRIME_TICKS_REQUIRED = 2;
const PRIME_RESET_MS = 600;
/* ------------------------------------------------------------------ */
/* Reactive state */
/* ------------------------------------------------------------------ */
const hostRef = ref<HTMLDivElement | null>(null);
const status = ref("Loading tldraw engine...");
@@ -33,177 +31,353 @@ const status = ref("Loading tldraw engine...");
const activeLevel = ref(0);
const activeLabel = ref("Year 2026");
const debugInfo = computed(() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`);
const debugInfo = computed(
() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`,
);
let reactRoot: { unmount: () => void } | null = null;
let teardown: (() => void) | null = null;
function buildScene(api: TldrawApi) {
const nodes = new Map<string, ShapeNode>();
const children = new Map<string, string[]>();
/* ------------------------------------------------------------------ */
/* Shape builders — one function per zoom level */
/* ------------------------------------------------------------------ */
const attachNode = (node: ShapeNode) => {
nodes.set(node.id, node);
if (!node.parentId) return;
if (!children.has(node.parentId)) children.set(node.parentId, []);
children.get(node.parentId)?.push(node.id);
};
type ShapeDef = {
id: string;
type: "geo";
x: number;
y: number;
isLocked: boolean;
props: Record<string, unknown>;
meta: Record<string, unknown>;
};
const yearId = api.createShapeId("year-2026");
attachNode({
id: yearId,
level: 0,
parentId: null,
label: "Year 2026",
bounds: { x: 0, y: 0, w: 3200, h: 2200 },
});
/** Level 0 — year view: 1 container + 12 month cards */
function buildYearShapes(): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId("year-2026");
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
const monthCols = 4;
const monthRows = 3;
const monthGap = 34;
const monthW = 730;
const monthH = 650;
const monthStartX = 70;
const monthStartY = 90;
for (let month = 0; month < 12; month += 1) {
const col = month % monthCols;
const row = Math.floor(month / monthCols) % monthRows;
const x = monthStartX + col * (monthW + monthGap);
const y = monthStartY + row * (monthH + monthGap);
const monthId = api.createShapeId(`month-${month + 1}`);
const monthLabel = MONTH_LABELS[month] ?? `Month ${month + 1}`;
attachNode({
id: monthId,
level: 1,
parentId: yearId,
label: monthLabel,
bounds: { x, y, w: monthW, h: monthH },
});
const weekGap = 10;
const weekRows = 6;
const weekX = x + 14;
const weekY = y + 54;
const weekW = monthW - 28;
const weekH = (monthH - 68 - weekGap * (weekRows - 1)) / weekRows;
for (let week = 0; week < weekRows; week += 1) {
const wy = weekY + week * (weekH + weekGap);
const weekId = api.createShapeId(`month-${month + 1}-week-${week + 1}`);
attachNode({
id: weekId,
level: 2,
parentId: monthId,
label: `Week ${week + 1}`,
bounds: { x: weekX, y: wy, w: weekW, h: weekH },
});
const dayGap = 8;
const dayCols = 7;
const dayX = weekX + 10;
const dayY = wy + 28;
const dayW = (weekW - 20 - dayGap * (dayCols - 1)) / dayCols;
const dayH = weekH - 36;
for (let day = 0; day < dayCols; day += 1) {
const dx = dayX + day * (dayW + dayGap);
const dayId = api.createShapeId(`month-${month + 1}-week-${week + 1}-day-${day + 1}`);
attachNode({
id: dayId,
level: 3,
parentId: weekId,
label: `Day ${day + 1}`,
bounds: { x: dx, y: dayY, w: dayW, h: dayH },
});
}
}
}
return { yearId, nodes, children };
}
function createShapesFromScene(api: TldrawApi, nodes: Map<string, ShapeNode>) {
const list: Array<Record<string, unknown>> = [];
for (const node of nodes.values()) {
list.push({
id: node.id,
// Year container
shapes.push({
id: containerId,
type: "geo",
x: node.bounds.x,
y: node.bounds.y,
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: node.bounds.w,
h: node.bounds.h,
richText: api.toRichText(node.label),
w: 3200,
h: 2200,
richText: toRichText("2026"),
color: "black",
fill: "none",
dash: "draw",
},
meta: {
level: node.level,
label: node.label,
meta: { level: 0, label: "Year 2026", role: "container" },
});
const cols = 4;
const gap = 34;
const cardW = 730;
const cardH = 650;
const startX = 70;
const startY = 90;
for (let i = 0; i < 12; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = startX + col * (cardW + gap);
const y = startY + row * (cardH + gap);
const id = createShapeId(`month-${i}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x,
y,
isLocked: true,
props: {
geo: "rectangle",
w: cardW,
h: cardH,
richText: toRichText(MONTH_LABELS[i]!),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 1, label: MONTH_LABELS[i], role: "child", index: i },
});
}
return list;
return { shapes, containerId, childIds };
}
function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
const { yearId, nodes, children } = buildScene(api);
const shapes = createShapesFromScene(api, nodes);
editor.createShapes(shapes);
/** Level 1 — month view: 1 month container + 6 week rows */
function buildMonthShapes(monthIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`month-${monthIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 2400,
h: 1800,
richText: toRichText(MONTH_LABELS[monthIndex]!),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 1, label: MONTH_LABELS[monthIndex], role: "container", index: monthIndex },
});
const weekGap = 20;
const weekH = (1800 - 100 - weekGap * 5) / 6;
const weekW = 2400 - 80;
const weekX = 40;
const weekStartY = 80;
for (let w = 0; w < 6; w++) {
const y = weekStartY + w * (weekH + weekGap);
const id = createShapeId(`month-${monthIndex}-week-${w}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x: weekX,
y,
isLocked: true,
props: {
geo: "rectangle",
w: weekW,
h: weekH,
richText: toRichText(`Week ${w + 1}`),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 2, label: `Week ${w + 1}`, role: "child", index: w },
});
}
return { shapes, containerId, childIds };
}
/** Level 2 — week view: 1 week container + 7 day columns */
function buildWeekShapes(monthIndex: number, weekIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`week-${monthIndex}-${weekIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 2800,
h: 1600,
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1}`),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 2, label: `Week ${weekIndex + 1}`, role: "container", monthIndex, weekIndex },
});
const dayGap = 16;
const dayW = (2800 - 80 - dayGap * 6) / 7;
const dayH = 1600 - 120;
const dayX = 40;
const dayY = 80;
for (let d = 0; d < 7; d++) {
const x = dayX + d * (dayW + dayGap);
const id = createShapeId(`week-${monthIndex}-${weekIndex}-day-${d}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x,
y: dayY,
isLocked: true,
props: {
geo: "rectangle",
w: dayW,
h: dayH,
richText: toRichText(DAY_LABELS[d]!),
color: "black",
fill: "semi",
dash: "draw",
},
meta: { level: 3, label: DAY_LABELS[d], role: "child", index: d },
});
}
return { shapes, containerId, childIds };
}
/** Level 3 — day view: 1 day container + time slot blocks */
function buildDayShapes(monthIndex: number, weekIndex: number, dayIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
const containerId = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-expanded`);
const shapes: ShapeDef[] = [];
const childIds: string[] = [];
const dayLabel = DAY_LABELS[dayIndex] ?? `Day ${dayIndex + 1}`;
shapes.push({
id: containerId,
type: "geo",
x: 0,
y: 0,
isLocked: true,
props: {
geo: "rectangle",
w: 1200,
h: 2400,
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1} · ${dayLabel}`),
color: "black",
fill: "none",
dash: "draw",
},
meta: { level: 3, label: dayLabel, role: "container", monthIndex, weekIndex, dayIndex },
});
// Time slots from 8:00 to 20:00 (12 hour-slots)
const slotGap = 12;
const slotH = (2400 - 100 - slotGap * 11) / 12;
const slotW = 1200 - 80;
const slotX = 40;
const slotStartY = 80;
for (let s = 0; s < 12; s++) {
const y = slotStartY + s * (slotH + slotGap);
const hour = 8 + s;
const id = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-slot-${s}`);
childIds.push(id);
shapes.push({
id,
type: "geo",
x: slotX,
y,
isLocked: true,
props: {
geo: "rectangle",
w: slotW,
h: slotH,
richText: toRichText(`${hour}:00`),
color: "light-violet",
fill: "semi",
dash: "draw",
},
meta: { level: 4, label: `${hour}:00`, role: "slot", index: s },
});
}
return { shapes, containerId, childIds };
}
/* ------------------------------------------------------------------ */
/* Main zoom flow controller */
/* ------------------------------------------------------------------ */
function setupZoomFlow(editor: any, host: HTMLDivElement) {
let currentLevel = 0;
let selectedMonth = 0;
let selectedWeek = 0;
let selectedDay = 0;
let containerId = "";
let childIds: string[] = [];
let activeId = yearId;
let animating = false;
let token = 0;
const moveTo = (shapeId: string, immediate = false) => {
const bounds = editor.getShapePageBounds(shapeId);
if (!bounds) return;
// Wheel prime state
let primeDirection: "in" | "out" | "" = "";
let primeTicks = 0;
let primeTimer: ReturnType<typeof setTimeout> | null = null;
/** Wipe canvas and draw shapes for a given level */
function renderLevel(level: number, immediate: boolean) {
// Delete all existing shapes
const allShapeIds = editor.getCurrentPageShapeIds();
if (allShapeIds.size > 0) {
editor.deleteShapes([...allShapeIds]);
}
let result: { shapes: ShapeDef[]; containerId: string; childIds: string[] };
switch (level) {
case 0:
result = buildYearShapes();
break;
case 1:
result = buildMonthShapes(selectedMonth);
break;
case 2:
result = buildWeekShapes(selectedMonth, selectedWeek);
break;
case 3:
result = buildDayShapes(selectedMonth, selectedWeek, selectedDay);
break;
default:
return;
}
editor.createShapes(result.shapes);
containerId = result.containerId;
childIds = result.childIds;
currentLevel = level;
// Update reactive debug state
activeLevel.value = level;
const containerMeta = result.shapes.find((s) => s.id === containerId)?.meta;
activeLabel.value = (containerMeta?.label as string) ?? LEVEL_LABELS[level] ?? "";
// Zoom camera to fit container
const bounds = editor.getShapePageBounds(containerId);
if (bounds) {
token += 1;
const localToken = token;
const duration = immediate ? 0 : ZOOM_MS;
animating = true;
animating = !immediate;
editor.stopCameraAnimation();
editor.zoomToBounds(bounds, {
inset: 20,
inset: 40,
animation: { duration },
immediate,
force: true,
});
activeId = shapeId;
const node = nodes.get(shapeId);
if (node) {
activeLevel.value = node.level;
activeLabel.value = node.label;
if (!immediate) {
setTimeout(() => {
if (localToken === token) animating = false;
}, duration + 50);
}
}
}
window.setTimeout(() => {
if (localToken !== token) return;
animating = false;
}, duration + 40);
};
/** Resolve which child the mouse is hovering over */
function resolveHoveredChild(): string | null {
if (childIds.length === 0) return null;
const firstChild = (shapeId: string) => {
const next = children.get(shapeId);
return next?.[0] ?? null;
};
const pointer = editor.inputs.currentPagePoint;
if (!pointer) return childIds[0] ?? null;
const resolveChildFromHover = (shapeId: string) => {
const nextIds = children.get(shapeId) ?? [];
if (!nextIds.length) return null;
const pointer = editor.inputs.getCurrentPagePoint();
const hit = editor.getShapeAtPoint(pointer, {
margin: 0,
hitInside: true,
@@ -212,73 +386,148 @@ function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
hitFrameInside: true,
});
if (!hit) return firstChild(shapeId);
let cursor: string | null = hit.id ?? null;
while (cursor && nodes.get(cursor)?.parentId !== shapeId) {
cursor = nodes.get(cursor)?.parentId ?? null;
if (hit && childIds.includes(hit.id)) return hit.id;
// If hit is the container itself, try first child
return childIds[0] ?? null;
}
if (cursor && nextIds.includes(cursor)) return cursor;
return firstChild(shapeId);
};
function resetPrime() {
primeDirection = "";
primeTicks = 0;
if (primeTimer) {
clearTimeout(primeTimer);
primeTimer = null;
}
}
const zoomIn = () => {
const node = nodes.get(activeId);
if (!node || node.level >= 3) return;
const target = resolveChildFromHover(activeId);
if (!target) return;
moveTo(target, false);
};
function zoomIn() {
if (currentLevel >= 3) return;
const zoomOut = () => {
const parent = nodes.get(activeId)?.parentId;
if (!parent) return;
moveTo(parent, false);
};
const targetId = resolveHoveredChild();
if (!targetId) return;
const onWheel = (event: WheelEvent) => {
// Find the index of hovered child from its meta
const shape = editor.getShape(targetId);
const idx = (shape?.meta?.index as number) ?? 0;
switch (currentLevel) {
case 0:
selectedMonth = idx;
break;
case 1:
selectedWeek = idx;
break;
case 2:
selectedDay = idx;
break;
}
renderLevel(currentLevel + 1, false);
}
function zoomOut() {
if (currentLevel <= 0) return;
renderLevel(currentLevel - 1, false);
}
function onWheel(event: WheelEvent) {
event.preventDefault();
if (animating) return;
if (event.deltaY < 0) {
zoomIn();
const direction: "in" | "out" = event.deltaY < 0 ? "in" : "out";
// Check if can go in this direction
if (direction === "in" && currentLevel >= 3) return;
if (direction === "out" && currentLevel <= 0) return;
// Reset prime if direction changed
if (primeDirection !== direction) {
resetPrime();
primeDirection = direction;
}
primeTicks += 1;
// Reset prime timer
if (primeTimer) clearTimeout(primeTimer);
primeTimer = setTimeout(resetPrime, PRIME_RESET_MS);
// Highlight target on pre-ticks
if (primeTicks < PRIME_TICKS_REQUIRED) {
// Visual hint: briefly scale hovered child
if (direction === "in") {
const targetId = resolveHoveredChild();
if (targetId) {
const shape = editor.getShape(targetId);
if (shape) {
// Flash the shape by toggling fill
editor.updateShape({
id: targetId,
type: "geo",
props: { fill: "solid" },
});
setTimeout(() => {
try {
editor.updateShape({
id: targetId,
type: "geo",
props: { fill: "semi" },
});
} catch (_) { /* shape might be gone */ }
}, 150);
}
}
}
return;
}
// Enough ticks — execute zoom
resetPrime();
if (direction === "in") {
zoomIn();
} else {
zoomOut();
};
}
}
const onDoubleClick = () => {
function onDoubleClick() {
if (animating) return;
moveTo(yearId, false);
};
resetPrime();
if (currentLevel === 0) return;
renderLevel(0, false);
}
// Initial render
renderLevel(0, true);
// Event listeners on host element (outside tldraw's event system)
host.addEventListener("wheel", onWheel, { passive: false });
host.addEventListener("dblclick", onDoubleClick);
moveTo(yearId, true);
return () => {
resetPrime();
host.removeEventListener("wheel", onWheel);
host.removeEventListener("dblclick", onDoubleClick);
};
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
onMounted(async () => {
try {
if (!hostRef.value) return;
const api = {
Tldraw,
createShapeId,
toRichText,
} satisfies TldrawApi;
reactRoot = createRoot(hostRef.value);
reactRoot.render(
createElement(api.Tldraw, {
createElement(Tldraw, {
hideUi: true,
onMount: (editor: any) => {
status.value = "Wheel up = zoom in, wheel down = zoom out, double click = reset";
teardown = setupZoomFlow(editor, api, hostRef.value as HTMLDivElement);
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement);
},
}),
);