Gravity sim (xkcd 2347)

Rebuild the xkcd "Dependency" comic as geo shapes and drop it into a Rapier 2D physics simulation.

import RAPIER from '@dimforge/rapier2d-compat'
import { useEffect, useRef } from 'react'
import { createShapeId, Tldraw, TLShapeId, toRichText, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'

// Block positions from the xkcd "Dependency" comic (#2347), as [x, y, w, h] quads with y=0 at
// the bottom of the tower. Source: https://editor.p5js.org/isohedral/sketches/vJa5RiZWs
const RAW_DATA = [
	0.5, 0.5, 217.113, 16.4414, 14.168, 16.9414, 181.059, 19.4141, 28.4297, 36.3555, 55.668, 10.6953,
	158.977, 36.3555, 7.921, 25.7539, 36.5547, 47.0508, 42.9844, 15.0586, 31.9961, 62.1094, 144.4099,
	8.7148, 23.2891, 70.8242, 159.1059, 48.2578, 15.918, 119.082, 83.4336, 106.715, 20.1875, 225.797,
	71.4023, 13.973, 102.844, 119.082, 85.371, 32.207, 102.844, 151.289, 89.254, 33.762, 24.9766,
	239.77, 14.6132, 19.457, 47.6055, 239.77, 11.7851, 13.562, 64.5781, 239.77, 27.0117, 20.636,
	66.4609, 260.406, 21.2149, 41.016, 69.2891, 301.422, 6.3672, 15.558, 79.4258, 301.422, 6.6015,
	25.457, 64.5781, 316.98, 12.4922, 4.95, 75.6563, 326.879, 13.4375, 9.43, 75.3047, 336.309, 6.3086,
	9.07, 84.3086, 336.309, 5.9609, 8.554, 52.375, 253.332, 5.2578, 15.426, 21.1602, 259.227, 27.3515,
	7.675, 20.1875, 266.902, 5.6094, 7.883, 28.5781, 266.902, 5.7188, 14.012, 38.7773, 266.902,
	4.6368, 10.2, 22.9922, 280.914, 17.332, 5.149, 27.1875, 286.063, 5.0938, 27.351, 27.1875, 313.414,
	4.4688, 10.82, 34.8359, 286.063, 9.3516, 10.355, 35.5313, 296.418, 7.8828, 7.879, 105.641,
	185.051, 13.078, 11.426, 104.254, 196.477, 4.359, 6.101, 109.813, 196.477, 4.96, 19.429, 106.434,
	215.906, 10.968, 6.231, 109.813, 222.137, 3.601, 6.617, 181.543, 185.051, 12.844, 19.719, 183.684,
	204.77, 5.754, 14.253, 179.27, 219.023, 15.117, 8.622, 180.207, 227.645, 4.547, 7.89, 189.438,
	227.645, 4.414, 6.421, 123.215, 185.051, 20.336, 52.625, 137.531, 237.676, 9.938, 11.238, 127.766,
	237.676, 8.16, 24.215, 152.379, 185.051, 23.414, 68.812, 156.125, 253.863, 14.383, 14.383,
	159.605, 268.246, 9.497, 10.367, 121.609, 261.891, 19.133, 16.722, 116.395, 278.613, 59.398,
	10.703, 166.961, 289.316, 12.309, 21.004, 170.508, 310.32, 5.285, 12.844, 113.414, 289.316,
	11.941, 10.5, 118.668, 299.816, 5.352, 13.715, 115.59, 313.531, 10.433, 6.555, 131.176, 289.316,
	30.703, 38.129, 130.305, 327.445, 14.582, 9.899, 150.641, 327.445, 14.984, 9.899, 137.598,
	337.344, 22.742, 9.765, 138.867, 347.109, 4.684, 14.18, 147.469, 347.109, 3.976, 10.301, 153.852,
	347.109, 4.949, 10.301,
]

const CANVAS_H = 434
const SCALE = 3

// The "critical dependency" block near the base of the tower
const DEPENDENCY_BLOCK_INDEX = 3

const COLORS = [
	'light-blue',
	'light-green',
	'yellow',
	'orange',
	'light-red',
	'light-violet',
	'blue',
	'green',
	'red',
	'violet',
] as const

interface Block {
	x: number
	y: number
	w: number
	h: number
}

function processBlocks(): Block[] {
	const blocks: Block[] = []
	const scaled = RAW_DATA.map((v) => v * 1.07)
	for (let i = 0; i < scaled.length; i += 4) {
		const x = (scaled[i] + 20) * SCALE
		const y = (CANVAS_H - scaled[i + 1] - scaled[i + 3] - 20) * SCALE
		const w = scaled[i + 2] * SCALE
		const h = scaled[i + 3] * SCALE
		blocks.push({ x, y, w, h })
	}
	return blocks
}

function XkcdDependency() {
	const editor = useEditor()
	const worldRef = useRef<RAPIER.World | null>(null)
	const bodiesRef = useRef<{ id: TLShapeId; body: RAPIER.RigidBody; w: number; h: number }[]>([])

	useEffect(() => {
		let cancelled = false
		let onTick: (() => void) | null = null
		let removeListener: (() => void) | null = null

		const blocks = processBlocks()
		const depBlock = blocks[DEPENDENCY_BLOCK_INDEX]

		const shapes = blocks.map((block, i) => {
			const id = createShapeId(`xkcd-${i}`)
			return {
				id,
				type: 'geo' as const,
				x: block.x,
				y: block.y,
				props: {
					w: block.w,
					h: block.h,
					fill: 'solid' as const,
					color: i === DEPENDENCY_BLOCK_INDEX ? ('red' as const) : COLORS[i % COLORS.length],
					geo: 'rectangle' as const,
					size: 's' as const,
				},
			}
		})

		const titleId = createShapeId('xkcd-title')
		const minX = Math.min(...blocks.map((b) => b.x))
		const maxX = Math.max(...blocks.map((b) => b.x + b.w))
		const minY = Math.min(...blocks.map((b) => b.y))

		const annotationId = createShapeId('xkcd-annotation')
		const arrowId = createShapeId('xkcd-arrow')
		const depId = createShapeId(`xkcd-${DEPENDENCY_BLOCK_INDEX}`)
		const creditId = createShapeId('xkcd-credit')
		const groundY = blocks.reduce((max, b) => Math.max(max, b.y + b.h), 0) + 10

		editor.createShapes([
			...shapes,
			{
				id: titleId,
				type: 'text',
				x: minX + (maxX - minX) / 2 - 200,
				y: minY - 80,
				props: {
					richText: toRichText('All modern digital infrastructure'),
					size: 'l',
					color: 'black',
				},
			},
			{
				id: annotationId,
				type: 'text',
				x: depBlock.x + 220,
				y: depBlock.y + depBlock.h - 300,
				props: {
					richText: toRichText(
						'A project some\nrandom person\nin Nebraska has\nbeen thanklessly\nmaintaining\nsince 2003'
					),
					size: 's',
					color: 'black',
				},
			},
			{
				id: arrowId,
				type: 'arrow',
				x: depBlock.x - 30,
				y: depBlock.y + depBlock.h + 50,
				props: {
					start: { x: 0, y: 0 },
					end: { x: 30, y: -50 },
					color: 'black',
					size: 's',
					kind: 'arc',
					bend: -70,
					elbowMidPoint: 0.5,
					arrowheadStart: 'none',
					arrowheadEnd: 'arrow',
				},
			},
			{
				id: creditId,
				type: 'text',
				x: minX,
				y: groundY + 30,
				props: {
					richText: {
						type: 'doc',
						content: [
							{
								type: 'paragraph',
								content: [
									{ type: 'text', text: 'With apologies to ' },
									{
										type: 'text',
										text: 'xkcd.com/2347',
										marks: [{ type: 'link', attrs: { href: 'https://xkcd.com/2347' } }],
									},
								],
							},
							{
								type: 'paragraph',
								content: [
									{ type: 'text', text: 'Inspired by ' },
									{
										type: 'text',
										text: 'editor.p5js.org/isohedral',
										marks: [
											{
												type: 'link',
												attrs: {
													href: 'https://editor.p5js.org/isohedral/full/vJa5RiZWs',
												},
											},
										],
									},
								],
							},
						],
					},
					size: 's',
					color: 'grey',
				},
			},
		])

		editor.createBindings([
			{
				fromId: arrowId,
				toId: depId,
				type: 'arrow',
				props: {
					terminal: 'end',
					normalizedAnchor: { x: 0.5, y: 0.5 },
					isPrecise: false,
					isExact: false,
				},
			},
			{
				fromId: arrowId,
				toId: annotationId,
				type: 'arrow',
				props: {
					terminal: 'start',
					normalizedAnchor: { x: 0.5, y: 0.5 },
					isPrecise: true,
					isExact: false,
				},
			},
		])

		editor.zoomToFit({ animation: { duration: 300 } })

		// [1]
		RAPIER.init().then(() => {
			if (cancelled) return

			const world = new RAPIER.World({ x: 0, y: 200 })
			worldRef.current = world

			const groundBody = world.createRigidBody(
				RAPIER.RigidBodyDesc.fixed().setTranslation((minX + maxX) / 2, groundY + 50)
			)
			world.createCollider(RAPIER.ColliderDesc.cuboid(((maxX - minX) * 3) / 2, 50), groundBody)

			const blockIdSet = new Set<string>()
			const bodies: typeof bodiesRef.current = []
			for (let i = 0; i < blocks.length; i++) {
				const block = blocks[i]
				const id = createShapeId(`xkcd-${i}`)
				blockIdSet.add(id)
				const body = world.createRigidBody(
					RAPIER.RigidBodyDesc.dynamic()
						.setTranslation(block.x + block.w / 2, block.y + block.h / 2)
						.setCanSleep(true)
				)
				world.createCollider(
					RAPIER.ColliderDesc.cuboid(block.w / 2, block.h / 2)
						.setRestitution(0.05)
						.setFriction(0.1),
					body
				)
				bodies.push({ id, body, w: block.w, h: block.h })
			}
			bodiesRef.current = bodies

			// [2]
			for (let i = 0; i < 120; i++) world.step()
			const settled: { id: TLShapeId; type: 'geo'; x: number; y: number; rotation: number }[] = []
			for (const { id, body, w, h } of bodies) {
				const pos = body.translation()
				settled.push({
					id,
					type: 'geo',
					x: pos.x - w / 2,
					y: pos.y - h / 2,
					rotation: body.rotation(),
				})
				body.sleep()
			}
			editor.updateShapes(settled)

			let updatingFromPhysics = false
			const kinematicIds = new Set<string>()

			// [3]
			removeListener = editor.store.listen(
				({ changes }) => {
					if (updatingFromPhysics) return

					for (const key of Object.keys(changes.removed)) {
						if (!blockIdSet.has(key)) continue
						blockIdSet.delete(key)
						kinematicIds.delete(key)
						const idx = bodiesRef.current.findIndex((b) => b.id === key)
						if (idx !== -1) {
							world.removeRigidBody(bodiesRef.current[idx].body)
							bodiesRef.current.splice(idx, 1)
						}
						for (const { body } of bodiesRef.current) body.wakeUp()
					}
				},
				{ source: 'user', scope: 'document' }
			)

			// [4]
			onTick = () => {
				if (!worldRef.current) return

				const selectedIds = new Set(editor.getSelectedShapeIds() as string[])

				// Release bodies that are no longer selected back to dynamic
				for (const id of kinematicIds) {
					if (selectedIds.has(id)) continue
					kinematicIds.delete(id)
					const entry = bodiesRef.current.find((b) => b.id === id)
					if (!entry) continue
					entry.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true)
					entry.body.setLinvel({ x: 0, y: 0 }, true)
					entry.body.setAngvel(0, true)
					for (const { body } of bodiesRef.current) body.wakeUp()
				}

				// Switch selected bodies to kinematic so they follow the user's drag
				for (const entry of bodiesRef.current) {
					if (!selectedIds.has(entry.id as string)) continue
					if (!kinematicIds.has(entry.id as string)) {
						entry.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true)
						kinematicIds.add(entry.id as string)
					}
					const shape = editor.getShape(entry.id)
					if (!shape) continue
					entry.body.setNextKinematicTranslation({
						x: shape.x + entry.w / 2,
						y: shape.y + entry.h / 2,
					})
					entry.body.setNextKinematicRotation(shape.rotation)
				}

				world.step()

				// Sync non-selected, non-sleeping bodies back to tldraw shapes
				const updates: {
					id: TLShapeId
					type: 'geo'
					x: number
					y: number
					rotation: number
				}[] = []
				for (const { id, body, w, h } of bodiesRef.current) {
					if (kinematicIds.has(id as string)) continue
					if (body.isSleeping()) continue
					const pos = body.translation()
					const bx = pos.x - w / 2
					const by = pos.y - h / 2
					if (!isFinite(bx) || !isFinite(by)) continue
					updates.push({ id, type: 'geo', x: bx, y: by, rotation: body.rotation() })
				}

				if (updates.length > 0) {
					updatingFromPhysics = true
					editor.updateShapes(updates)
					updatingFromPhysics = false
				}
			}

			editor.on('tick', onTick)
		})

		return () => {
			cancelled = true
			if (onTick) editor.off('tick', onTick)
			if (removeListener) removeListener()
			if (worldRef.current) {
				worldRef.current.free()
				worldRef.current = null
			}
			const shapeIds: TLShapeId[] = blocks.map((_, i) => createShapeId(`xkcd-${i}`))
			shapeIds.push(titleId, annotationId, arrowId, creditId)
			editor.deleteShapes(shapeIds)
		}
	}, [editor])

	return null
}

export default function XkcdDependencyExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw>
				<XkcdDependency />
			</Tldraw>
		</div>
	)
}

/*
[1]
Rapier is a WASM module and must be initialised asynchronously. The shapes are created first
so the comic appears immediately; physics attaches once the module is ready.

[2]
The traced block positions overlap slightly, so the world is stepped a number of times before
the first frame to let them settle, then every body is put to sleep so the tower is still
until something disturbs it.

[3]
When the user deletes a block, its rigid body is removed too and everything is woken so the
tower re-settles. `source: 'user'` filters out the position updates we write ourselves.

[4]
Every editor `tick`, selected blocks are switched to kinematic bodies that follow the shape's
position (so dragging moves the physics body, not the other way round), the world is stepped,
and every awake, unselected body writes its position and rotation back to its shape with
`editor.updateShapes`.
*/

Each block in the comic is a geo shape with a matching Rapier rigid body. On every editor tick the world steps and awake bodies write their position and rotation back to their shapes with editor.updateShapes. Selected blocks become kinematic and follow the shape instead, so you can drag them; deleting a block removes its body and wakes the rest.

Try pulling out the red block near the bottom and watch everything collapse, just like in the original comic.

Is this page helpful?
Prev
Customize Mermaid diagrams
Next
Hundreds of Mermaid diagrams