Building Interactive Pan/Zoom Controls I Didn't Need

- Published on
- /18 mins read
📚 Series: Mermaid Diagrams in Next.js
- Part 1: When Build-Time Rendering Seemed Like a Good Idea
- Part 2: Building Interactive Controls I Didn't Need ← You are here
- Part 3: Simple CSS Fixed Everything
Previously in Part 1: I attempted build-time SVG generation with Puppeteer, which increased build times from 5s to 45s. The theoretical performance benefits weren't worth the practical developer experience costs, so I abandoned the approach.
Architecture 2: Interactive Client-Side (The Over-Engineering Phase)
📦 Mermaid Version Note: This tutorial uses Mermaid v10.9.1. The code examples and API calls shown are compatible with v10.x.
For Mermaid v11.x users: Breaking API change in v11.0.0+
- Replace
mermaid.initialize()withawait mermaid.run()- See the official migration guide for full details
- Bundle size increased from ~373 KB (v10.x) to ~480 KB (v11.x)
The Pivot: Embracing Client-Side Rendering
After abandoning build-time rendering, I went back to basics. The existing client-side Mermaid implementation worked perfectly:
- User visits page with blog post
- Browser downloads Mermaid.js (~373 KB, code-split)
- Mermaid parses diagram syntax
- SVG renders in browser
- Done!
Simple. Reliable. Boring.
But then I had an idea...
"If we're already running JavaScript in the browser, why not make diagrams interactive? Add pan and zoom controls like Google Maps! That would be so much better for readers!"
This is the moment where I went from solving a problem to adding features nobody asked for. Looking back, this was over-engineering in action.
My new vision:
- ✅ Render Mermaid.js client-side (keep what works)
- ✨ Add pan/zoom for large diagrams (sounds useful!)
- ✨ Momentum scrolling (like Google Maps—fancy!)
- ✨ Keyboard shortcuts (+ / - to zoom—power users!)
- ✨ Hover-only controls (clean UI—designer-approved!)
- ✨ Touch gestures (pinch-to-zoom—mobile-first!)
Reading that list now, I can see the problem: I was building features because they sounded cool, not because users needed them. But I didn't realize it yet.
Understanding the Component Architecture
React components work together like LEGO blocks that snap together to build complex UIs.
What we're building:
Component hierarchy (bottom-up):
<MermaidInteractive enableControls={true}> ← User sees this in markdown
↓
<MermaidWithControls> ← Wrapper with pan/zoom logic
↓
<usePanZoom hook> ← 308 lines of state management
↓
<DiagramControls> ← 322 lines of UI buttons
↓
<Mermaid> ← Base component (renders SVG)
Each layer adds functionality, but also complexity. Let's build this from the ground up.
Step 1: The Basic Mermaid Component (Foundation)
First, I needed a solid base component that renders Mermaid diagrams with theme support:
'use client'
import mermaid from 'mermaid'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
// Initialize Mermaid once when module loads
mermaid.initialize({
startOnLoad: false, // We'll manually trigger rendering
theme: 'default', // Default light theme
})
interface MermaidProps {
chart: string // The diagram code like "graph TD\n A-->B"
}
export function Mermaid({ chart }: MermaidProps) {
// Refs: Direct access to DOM elements (bypassing React's virtual DOM)
const containerRef = useRef<HTMLDivElement>(null)
// Get current theme (light/dark) from context
const { theme } = useTheme()
// Re-render diagram when chart code or theme changes
useEffect(() => {
if (!containerRef.current) return
// Step 1: Clear previous diagram
containerRef.current.innerHTML = ''
// Step 2: Update Mermaid theme to match user preference
mermaid.initialize({
theme: theme === 'dark' ? 'dark' : 'default',
})
// Step 3: Generate unique ID for this diagram
// (Mermaid needs unique IDs to track multiple diagrams on one page)
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`
// Step 4: Render the diagram
mermaid
.render(id, chart)
.then(({ svg }) => {
// Inject SVG into our container
if (containerRef.current) {
containerRef.current.innerHTML = svg
}
})
.catch((error) => {
console.error('Mermaid rendering failed:', error)
// Show error to user instead of blank space
if (containerRef.current) {
containerRef.current.innerHTML = `
<div style="color: red; border: 1px solid red; padding: 1rem;">
Failed to render diagram: ${error.message}
</div>
`
}
})
}, [chart, theme]) // Dependencies: re-run when these change
return (
<div
ref={containerRef}
className="mermaid-container my-8"
// Accessible: screen readers announce this is a diagram
role="img"
aria-label="Mermaid diagram"
/>
)
}What this code does:
useRef- Creates a reference to the actual DOM element (not React's virtual DOM)useTheme- Subscribes to theme changes (light ↔ dark mode toggle)useEffect- Runs side effects (like rendering) when dependencies changemermaid.render()- Converts text → SVG asynchronously- Error handling - Shows helpful message if diagram code is invalid
Teaching moment: Why useEffect?
React's rendering is declarative: you describe what should be shown, not how to show it. But Mermaid.js is imperative: you must tell it to render at specific times.
useEffect bridges this gap: "After React renders this component, then run Mermaid."
This base component worked perfectly. But instead of stopping here, I thought: "Let's make it interactive!"
Step 2: Building the Pan/Zoom Hook (The Complex Part)
Custom React hooks encapsulate stateful logic that multiple components can reuse. I created usePanZoom to handle:
- Mouse wheel zoom (zoom toward cursor position)
- Click-and-drag panning (move diagram around)
- Momentum scrolling (continues moving after mouse release)
- Keyboard shortcuts (+ / - / R / F keys)
- Programmatic controls (zoom in/out buttons)
Here's the complete implementation with detailed explanations:
import { useCallback, useEffect, useRef, useState, RefObject } from 'react'
// Configuration options for pan/zoom behavior
interface PanZoomOptions {
minScale: number // Don't zoom out beyond this (e.g., 0.5 = 50%)
maxScale: number // Don't zoom in beyond this (e.g., 3 = 300%)
wheelSensitivity: number // How fast wheel zooms (0.01 = 1% per tick)
zoomStep: number // How much +/- buttons zoom (0.2 = 20%)
momentumDamping: number // Friction (0.92 = lose 8% speed per frame)
}
// Return type: functions caller can use
interface PanZoomControls {
transform: { x: number; y: number; scale: number }
zoomIn: () => void
zoomOut: () => void
reset: () => void
fitToView: () => void
isPanning: boolean
}
export function usePanZoom(
containerRef: RefObject<HTMLDivElement>, // The viewport (fixed size)
contentRef: RefObject<HTMLDivElement>, // The content (can be larger)
options: PanZoomOptions
): PanZoomControls {
// State: Current position and zoom level
const [transform, setTransform] = useState({
x: 0, // Pan offset X (pixels)
y: 0, // Pan offset Y (pixels)
scale: 1, // Zoom level (1 = 100%, 2 = 200%, etc.)
})
// State: Are we currently dragging?
const [isPanning, setIsPanning] = useState(false)
// State: Momentum velocity (for inertia after drag release)
const [momentum, setMomentum] = useState({
vx: 0, // Velocity X (pixels per millisecond)
vy: 0, // Velocity Y (pixels per millisecond)
})
// Refs: Store mutable values that don't trigger re-renders
const dragStartRef = useRef({ x: 0, y: 0 })
const lastPosRef = useRef({ x: 0, y: 0, time: 0 })
/**
* Mouse Wheel Handler: Zoom toward cursor position
*
* The math here is tricky! When zooming, we want the point under
* the cursor to stay fixed. This requires adjusting both scale AND pan.
*/
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault()
// Get viewport boundaries
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
// Calculate cursor position in viewport coordinates
const cursorX = e.clientX - rect.left
const cursorY = e.clientY - rect.top
// Calculate zoom delta (negative deltaY = zoom in)
const delta = -e.deltaY * options.wheelSensitivity
// Calculate new scale (clamped to min/max)
const newScale = Math.max(
options.minScale,
Math.min(options.maxScale, transform.scale * (1 + delta))
)
// How much did scale change? (e.g., 1.1x or 0.9x)
const scaleChange = newScale / transform.scale
/**
* Math explanation:
*
* Before zoom:
* cursorX = transform.x + (contentX * transform.scale)
*
* After zoom (keeping contentX fixed):
* cursorX = newTransform.x + (contentX * newScale)
*
* Solving for newTransform.x:
* newTransform.x = cursorX - (cursorX - transform.x) * scaleChange
*/
setTransform({
x: cursorX - (cursorX - transform.x) * scaleChange,
y: cursorY - (cursorY - transform.y) * scaleChange,
scale: newScale,
})
}, [transform, options, containerRef])
/**
* Mouse Down Handler: Start dragging
*
* This sets up event listeners for mouse movement and release.
* We track velocity for momentum scrolling.
*/
const handleMouseDown = useCallback((e: MouseEvent) => {
// Ignore if not left mouse button
if (e.button !== 0) return
setIsPanning(true)
// Remember where drag started (accounting for current pan)
dragStartRef.current = {
x: e.clientX - transform.x,
y: e.clientY - transform.y,
}
// Initialize velocity tracking
lastPosRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now(),
}
/**
* Mouse Move Handler: Update position while dragging
*/
const handleMouseMove = (e: MouseEvent) => {
const now = Date.now()
const dt = now - lastPosRef.current.time
// Calculate how far mouse moved
const dx = e.clientX - lastPosRef.current.x
const dy = e.clientY - lastPosRef.current.y
// Update transform position
setTransform({
...transform,
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
})
// Calculate velocity for momentum (pixels per millisecond)
if (dt > 0) {
setMomentum({
vx: dx / dt,
vy: dy / dt,
})
}
// Update tracking
lastPosRef.current = { x: e.clientX, y: e.clientY, time: now }
}
/**
* Mouse Up Handler: Stop dragging
*/
const handleMouseUp = () => {
setIsPanning(false)
// Remove event listeners
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// Attach listeners to document (so dragging outside viewport works)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [transform])
/**
* Programmatic zoom functions (for buttons and keyboard)
*/
const zoomIn = useCallback(() => {
setTransform((t) => ({
...t,
scale: Math.min(options.maxScale, t.scale * (1 + options.zoomStep)),
}))
}, [options])
const zoomOut = useCallback(() => {
setTransform((t) => ({
...t,
scale: Math.max(options.minScale, t.scale * (1 - options.zoomStep)),
}))
}, [options])
const reset = useCallback(() => {
setTransform({ x: 0, y: 0, scale: 1 })
setMomentum({ vx: 0, vy: 0 })
}, [])
const fitToView = useCallback(() => {
if (!containerRef.current || !contentRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
const contentRect = contentRef.current.getBoundingClientRect()
// Calculate scale to fit content in viewport (with 10% padding)
const scaleX = (containerRect.width * 0.9) / contentRect.width
const scaleY = (containerRect.height * 0.9) / contentRect.height
const scale = Math.min(scaleX, scaleY, options.maxScale)
// Center content
const x = (containerRect.width - contentRect.width * scale) / 2
const y = (containerRect.height - contentRect.height * scale) / 2
setTransform({ x, y, scale })
setMomentum({ vx: 0, vy: 0 })
}, [containerRef, contentRef, options])
/**
* Effect: Attach keyboard shortcuts
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
case '_':
e.preventDefault()
zoomOut()
break
case 'r':
case 'R':
e.preventDefault()
reset()
break
case 'f':
case 'F':
e.preventDefault()
fitToView()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [zoomIn, zoomOut, reset, fitToView])
/**
* Effect: Attach wheel handler
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [containerRef, handleWheel])
/**
* Effect: Attach mouse down handler
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('mousedown', handleMouseDown)
return () => container.removeEventListener('mousedown', handleMouseDown)
}, [containerRef, handleMouseDown])
/**
* Effect: Animate momentum (inertia after drag release)
*
* This creates smooth "throw" behavior like scrolling on mobile.
* Uses requestAnimationFrame for smooth 60fps animation.
*/
useEffect(() => {
// Stop if velocity is negligible
if (Math.abs(momentum.vx) < 0.01 && Math.abs(momentum.vy) < 0.01) {
setMomentum({ vx: 0, vy: 0 })
return
}
// Animation loop
const animate = () => {
// Apply velocity to position
setTransform((t) => ({
...t,
x: t.x + momentum.vx * 16, // * 16 ≈ one frame at 60fps
y: t.y + momentum.vy * 16,
}))
// Apply damping (friction)
setMomentum((m) => ({
vx: m.vx * options.momentumDamping,
vy: m.vy * options.momentumDamping,
}))
}
const id = requestAnimationFrame(animate)
// Cleanup: cancel animation if component unmounts
return () => cancelAnimationFrame(id)
}, [momentum, options.momentumDamping])
// Return controls for parent component to use
return {
transform,
zoomIn,
zoomOut,
reset,
fitToView,
isPanning,
}
}Wow. That's 308 lines of code. And we're not done yet!
Teaching moment: The cost of "nice interactions"
Notice how much complexity is hidden in "smooth pan and zoom":
- Physics simulation (momentum, damping)
- Coordinate system math (viewport vs content)
- Event handling (mouse, keyboard, wheel)
- Memory management (cleanup listeners)
- Edge cases (typing in inputs, boundary clamping)
Each feature multiplies complexity. This hook alone is more code than the entire simple solution we'll end up with.
Step 3: The Controls UI (Making It Pretty)
Next, I needed UI buttons for users who don't know keyboard shortcuts:
'use client'
import { useCallback, useState } from 'react'
interface DiagramControlsProps {
onZoomIn: () => void
onZoomOut: () => void
onReset: () => void
onFitToView: () => void
currentScale: number
isPanning: boolean
}
export function DiagramControls({
onZoomIn,
onZoomOut,
onReset,
onFitToView,
currentScale,
isPanning,
}: DiagramControlsProps) {
// State: Show controls only on hover
const [isHovered, setIsHovered] = useState(false)
return (
<div
className="relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Controls panel (hover-only) */}
<div
className={`
absolute top-2 right-2 z-10
flex flex-col gap-2
bg-white dark:bg-gray-800
rounded-lg shadow-lg
p-2
transition-opacity duration-200
${isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
>
{/* Zoom In Button */}
<button
onClick={onZoomIn}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Zoom In (+)"
aria-label="Zoom in"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 5v10M5 10h10" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Zoom Out Button */}
<button
onClick={onZoomOut}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Zoom Out (-)"
aria-label="Zoom out"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 10h10" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Reset Button */}
<button
onClick={onReset}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Reset (R)"
aria-label="Reset zoom and position"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 10a6 6 0 1112 0" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Fit to View Button */}
<button
onClick={onFitToView}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Fit to View (F)"
aria-label="Fit diagram to viewport"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<rect x="3" y="3" width="14" height="14" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Current Zoom Display */}
<div className="text-xs text-center text-gray-600 dark:text-gray-400 mt-2">
{Math.round(currentScale * 100)}%
</div>
</div>
{/* Panning indicator */}
{isPanning && (
<div className="absolute bottom-2 left-2 z-10 px-3 py-1 bg-blue-500 text-white text-sm rounded">
Panning...
</div>
)}
</div>
)
}Features I built:
- ✨ Hover-only UI (clean when not needed)
- ✨ Animated transitions (opacity fade)
- ✨ SVG icons (crisp at any size)
- ✨ Accessibility (aria-labels, keyboard shortcuts shown)
- ✨ Theme-aware colors (light/dark mode)
- ✨ Current zoom percentage display
- ✨ Panning status indicator
This alone was 322 lines of component code, styling, and SVG icons.
Step 4: Connecting Everything (The Wrapper)
Now I needed to connect the hook and controls:
'use client'
import { useRef } from 'react'
import { Mermaid } from './mermaid'
import { DiagramControls } from './diagram-controls'
import { usePanZoom } from '@/hooks/use-pan-zoom'
interface MermaidWithControlsProps {
chart: string
enableControls?: boolean
}
export function MermaidWithControls({
chart,
enableControls = true
}: MermaidWithControlsProps) {
// Refs for pan/zoom
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
// Hook up pan/zoom logic
const { transform, zoomIn, zoomOut, reset, fitToView, isPanning } = usePanZoom(
containerRef,
contentRef,
{
minScale: 0.5,
maxScale: 3,
wheelSensitivity: 0.001,
zoomStep: 0.2,
momentumDamping: 0.92,
}
)
return (
<div ref={containerRef} className="relative overflow-hidden border rounded-lg">
{/* Controls (if enabled) */}
{enableControls && (
<DiagramControls
onZoomIn={zoomIn}
onZoomOut={zoomOut}
onReset={reset}
onFitToView={fitToView}
currentScale={transform.scale}
isPanning={isPanning}
/>
)}
{/* Content with transforms applied */}
<div
ref={contentRef}
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: '0 0',
cursor: isPanning ? 'grabbing' : 'grab',
}}
>
<Mermaid chart={chart} />
</div>
</div>
)
}Finally, wrap in a code-split component for performance:
'use client'
import dynamic from 'next/dynamic'
// Code-split: only load when diagram is visible
const MermaidWithControls = dynamic(
() => import('./mermaid-with-controls').then((mod) => mod.MermaidWithControls),
{
ssr: false,
loading: () => <div className="animate-pulse bg-gray-200 h-64 rounded" />,
}
)
export function MermaidInteractive({ chart, enableControls = true }) {
return <MermaidWithControls chart={chart} enableControls={enableControls} />
}The Implementation Summary
Total code written:
usePanZoomhook: 308 linesDiagramControlscomponent: 322 linesMermaidWithControlswrapper: 38 linesMermaidInteractiveclient component: 38 lines- Grand total: 706 lines of code
For a feature that:
- Nobody requested
- Solved a problem users didn't have
- Added 30 KB to bundle size
- Required ongoing maintenance
- Introduced new bugs to fix
But it looked impressive! Smooth animations, momentum scrolling, keyboard shortcuts. I was proud of the engineering.
Then came the reality check...
The Pixelation Problem (When CSS Transforms Go Wrong)
After implementing everything, I tested on desktop. Zoomed in. And discovered: SVG diagrams were pixelated and blurry!
But oddly, they looked crisp on mobile. What was going on?
Root cause: CSS transform: scale() on wrapper div
My code was applying zoom like this:
/* My implementation (wrong!) */
.diagram-container {
transform: scale(1.5);
}The browser's rendering pipeline:
Why this happens:
- Browser renders SVG to bitmap at original size (say, 400×300 pixels)
- CSS
transform: scale(1.5)scales the bitmap (not the vector) - Stretching a 400×300 bitmap to 600×450 = pixelation
Why mobile was fine: Mobile devices have higher pixel density (2x, 3x retina), so the pixelation was less noticeable.
Teaching moment: Understanding browser rendering
SVG is a vector format—mathematical descriptions of shapes. It can scale infinitely without quality loss... if you let it.
But CSS transforms operate on the rendered output (pixels). By the time CSS transforms apply, the SVG has already been converted to a bitmap.
The Solution I Designed (But Never Implemented)
The correct approach: manipulate SVG's native viewBox attribute instead of CSS transforms.
// Instead of CSS transforms, manipulate SVG viewBox (vector-preserving)
const svg = svgRef.current
const viewBox = svg.getAttribute('viewBox') // e.g., "0 0 400 300"
const [x, y, width, height] = viewBox.split(' ').map(Number)
// Zoom by adjusting viewBox dimensions
// Smaller viewBox = more zoomed in (shows less of content)
svg.setAttribute('viewBox', `${x} ${y} ${width / scale} ${height / scale}`)How this works:
viewBox="0 0 400 300"means "show content from (0,0) to (400,300)"viewBox="0 0 200 150"means "show content from (0,0) to (200,150)"- Same viewport size, but showing less content = zoomed in
- Browser re-rasterizes SVG at new scale = crisp
The better approach:
I spent hours researching this solution. Wrote detailed specs. Planned the implementation.
But then I stopped and asked myself: "Do readers actually need to zoom and pan?"
The Moment of Truth (Realizing I Over-Engineered)
I looked at my blog posts:
- Most diagrams fit comfortably on screen
- Readers could use browser zoom (Cmd/Ctrl +/-) if needed
- Nobody had ever requested interactive diagrams
I looked at my codebase:
- 706 lines of complex interaction code
- Custom physics simulation
- Event handling edge cases
- Cross-browser testing burden
- Now SVG rendering bugs to debug
I looked at the user experience:
- Controls were hidden until hover (friction)
- Keyboard shortcuts required learning
- Touch gestures on mobile might conflict with scrolling
The question: Is this worth it?
The honest answer: No.
Architecture 2 Verdict
Let me score this honestly:
| Aspect | Score | Reality Check |
|---|---|---|
| Performance | 🟡 Medium | ~30 KB overhead for pan/zoom code |
| Bundle Size | 🟡 Medium | 373 KB Mermaid + 30 KB controls = 403 KB |
| User Experience | 🟡 Mixed | Smooth controls, but hidden by default |
| Code Complexity | 🔴 Terrible | 706 lines for a nice-to-have feature |
| Maintenance | 🔴 Terrible | Custom physics, event handling, browser bugs |
| Problem Solved | 🔴 Questionable | Solving a problem users didn't have |
Decision: After reviewing the implementation, I chose simplification.
Key learning I had:
Sometimes the best feature is the one you don't build. I was building what seemed cool from an engineering perspective, not what users actually needed.
The trap I fell into:
I started with a working solution (client-side Mermaid rendering). Then asked "what if we made it better?" instead of "what problems do users actually have?"
This is feature creep—adding features because you can, not because you should.
👉 Continue to Part 3: Simple CSS Fixed What 308 Lines of JavaScript Couldn't →
In the final post, I'll reveal the embarrassingly simple solution that actually shipped to production, complete with implementation guide and lessons learned.
👈 Previous: Part 1: When Build-Time Rendering Seemed Like a Good Idea
On this page
- Previously in Part 1: I attempted build-time SVG generation with Puppeteer, which increased build times from 5s to 45s. The theoretical performance benefits weren't worth the practical developer experience costs, so I abandoned the approach.
- Architecture 2: Interactive Client-Side (The Over-Engineering Phase)
- The Pivot: Embracing Client-Side Rendering
- Understanding the Component Architecture
- Step 1: The Basic Mermaid Component (Foundation)
- Step 2: Building the Pan/Zoom Hook (The Complex Part)
- Step 3: The Controls UI (Making It Pretty)
- Step 4: Connecting Everything (The Wrapper)
- The Implementation Summary
- The Pixelation Problem (When CSS Transforms Go Wrong)
- The Solution I Designed (But Never Implemented)
- The Moment of Truth (Realizing I Over-Engineered)
- Architecture 2 Verdict
- 👈 Previous: Part 1: When Build-Time Rendering Seemed Like a Good Idea
