Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@ runs:
with:
node-version: 24.13.1

- name: Restore node_modules cache
uses: actions/cache@v5
id: node-modules-cache
# Restore-only: PRs read from main's cache but never save their own copy.
# This avoids duplicating ~1GB of yarn cache per PR branch.
- name: Restore yarn cache
uses: actions/cache/restore@v5
id: yarn-cache
with:
path: |
node_modules
**/node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
key: ${{ runner.os }}-node-24.13.1-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }}
restore-keys: |
Expand All @@ -36,3 +35,14 @@ runs:
# Hardened mode enables --check-resolutions on public PRs, adding ~15s.
# See https://yarnpkg.com/features/security - Yarn recommends disabling in most jobs.
YARN_ENABLE_HARDENED_MODE: 0

# Only save the cache on pushes to main to avoid per-PR duplicates.
# Runs after install so the cache is fully populated.
- name: Save yarn cache
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.yarn-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: |
.yarn/cache
.yarn/install-state.gz
key: ${{ runner.os }}-node-24.13.1-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml', '.yarn/patches/*') }}
6 changes: 6 additions & 0 deletions apps/examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"ag-grid-react": "^32.3.3",
"axe-core": "^4.10.3",
"classnames": "^2.5.1",
"d3-geo": "^3.1.1",
"lazyrepo": "0.0.0-alpha.27",
"lodash": "^4.17.21",
"pdf-lib": "^1.17.1",
Expand All @@ -64,12 +65,17 @@
"react-helmet-async": "^1.3.0",
"react-router-dom": "^6.28.2",
"tldraw": "workspace:*",
"topojson-client": "^3.1.0",
"us-atlas": "^3.0.1",
"vite": "^7.0.1"
},
"devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/lodash": "^4.17.14",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"@vitejs/plugin-react-swc": "^3.10.2",
"dotenv": "^16.4.7",
"remark": "^15.0.1",
Expand Down
78 changes: 78 additions & 0 deletions apps/examples/src/examples/use-cases/d3-map/D3MapExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Editor, TLComponents, Tldraw, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import { UsMapShapeUtil } from './UsMapShapeUtil'
import { UsStateShapeUtil } from './UsStateShapeUtil'
import './d3-map.css'
import { MAP_HEIGHT, MAP_WIDTH } from './us-map-data'

export const STATE_COLORS = [
'#4e79a7',
'#f28e2b',
'#e15759',
'#76b7b2',
'#59a14f',
'#edc948',
'#b07aa1',
'#ff9da7',
'#9c755f',
'#bab0ac',
'#af7aa1',
'#d4a373',
]

const shapeUtils = [UsMapShapeUtil, UsStateShapeUtil]

function resetMap(editor: Editor) {
const allShapeIds = [...editor.getCurrentPageShapeIds()]
const mapAndStateIds = allShapeIds.filter((id) => {
const shape = editor.getShape(id)
return shape?.type === 'us-map' || shape?.type === 'us-state'
})
if (mapAndStateIds.length > 0) {
editor.deleteShapes(mapAndStateIds)
}
editor.createShape({
type: 'us-map',
x: 0,
y: 0,
props: { w: MAP_WIDTH, h: MAP_HEIGHT },
})
editor.zoomToFit({ animation: { duration: 200 } })
}

function TopPanel() {
const editor = useEditor()
return (
<div className="d3-map-top-panel">
<button className="d3-map-reset-button" onClick={() => resetMap(editor)}>
Reset map
</button>
</div>
)
}

const components: TLComponents = {
TopPanel,
}

export default function D3MapExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={shapeUtils}
components={components}
onMount={(editor) => {
if (editor.getCurrentPageShapeIds().size === 0) {
editor.createShape({
type: 'us-map',
x: 0,
y: 0,
props: { w: MAP_WIDTH, h: MAP_HEIGHT },
})
}
editor.zoomToFit({ animation: { duration: 0 } })
}}
/>
</div>
)
}
11 changes: 11 additions & 0 deletions apps/examples/src/examples/use-cases/d3-map/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: D3 geo map
component: ./D3MapExample.tsx
keywords: [d3, geo, map, geography, shapes, states, topojson, visualization]
---

A D3 geo map rendered as tldraw shapes that can be exploded into individually manipulable states.

---

This example demonstrates integrating a D3 geographic projection with tldraw's custom shape system. A composite US map shape can be "exploded" into individual state shapes that can be moved, resized, and selected independently. The explode operation is atomic and fully undoable.
132 changes: 132 additions & 0 deletions apps/examples/src/examples/use-cases/d3-map/UsMapShapeUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
Editor,
Geometry2d,
RecordProps,
Rectangle2d,
SVGContainer,
ShapeUtil,
T,
TLResizeInfo,
TLShape,
createShapeId,
resizeBox,
} from 'tldraw'
import { STATE_COLORS } from './D3MapExample'
import { MAP_HEIGHT, MAP_WIDTH, getUsStatesData } from './us-map-data'

const US_MAP_TYPE = 'us-map' as const

declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[US_MAP_TYPE]: { w: number; h: number }
}
}

export type UsMapShape = TLShape<typeof US_MAP_TYPE>

const statesData = getUsStatesData()

export function explodeMap(editor: Editor, shape: UsMapShape) {
const bounds = editor.getShapePageBounds(shape)
if (!bounds) return

const scaleX = bounds.w / MAP_WIDTH
const scaleY = bounds.h / MAP_HEIGHT

editor.run(
() => {
const newShapes = statesData.map((state, i) => ({
id: createShapeId(),
type: 'us-state' as const,
x: bounds.x + state.bounds.x * scaleX,
y: bounds.y + state.bounds.y * scaleY,
props: {
w: state.bounds.w * scaleX,
h: state.bounds.h * scaleY,
name: state.name,
pathData: state.pathData,
fill: STATE_COLORS[i % STATE_COLORS.length],
originalW: state.bounds.w,
originalH: state.bounds.h,
pathOffsetX: state.bounds.x,
pathOffsetY: state.bounds.y,
},
}))

editor.deleteShape(shape.id)
editor.createShapes(newShapes)
},
{ history: 'record-preserveRedoStack' }
)
}

export class UsMapShapeUtil extends ShapeUtil<UsMapShape> {
static override type = US_MAP_TYPE
static override props: RecordProps<UsMapShape> = {
w: T.number,
h: T.number,
}

getDefaultProps(): UsMapShape['props'] {
return { w: MAP_WIDTH, h: MAP_HEIGHT }
}

override canResize() {
return true
}
override isAspectRatioLocked() {
return true
}

getGeometry(shape: UsMapShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}

override onResize(shape: UsMapShape, info: TLResizeInfo<UsMapShape>) {
return resizeBox(shape, info)
}

override onDoubleClick(shape: UsMapShape) {
explodeMap(this.editor, shape)
}

component(shape: UsMapShape) {
const scaleX = shape.props.w / MAP_WIDTH
const scaleY = shape.props.h / MAP_HEIGHT

return (
<>
<SVGContainer>
<g transform={`scale(${scaleX}, ${scaleY})`}>
{statesData.map((state, i) => (
<path
key={state.id}
d={state.pathData}
fill={STATE_COLORS[i % STATE_COLORS.length]}
stroke="#fff"
strokeWidth={1 / scaleX}
strokeLinejoin="round"
/>
))}
</g>
</SVGContainer>
<button
className="d3-map-explode-button"
onPointerDown={(e) => e.stopPropagation()}
onClick={() => explodeMap(this.editor, shape)}
style={{ pointerEvents: 'all' }}
>
Explode states
</button>
</>
)
}

indicator(shape: UsMapShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
112 changes: 112 additions & 0 deletions apps/examples/src/examples/use-cases/d3-map/UsStateShapeUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
Geometry2d,
RecordProps,
Rectangle2d,
SVGContainer,
ShapeUtil,
T,
TLResizeInfo,
TLShape,
resizeBox,
} from 'tldraw'

const US_STATE_TYPE = 'us-state' as const

declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[US_STATE_TYPE]: {
w: number
h: number
name: string
pathData: string
fill: string
originalW: number
originalH: number
pathOffsetX: number
pathOffsetY: number
}
}
}

export type UsStateShape = TLShape<typeof US_STATE_TYPE>

export class UsStateShapeUtil extends ShapeUtil<UsStateShape> {
static override type = US_STATE_TYPE
static override props: RecordProps<UsStateShape> = {
w: T.number,
h: T.number,
name: T.string,
pathData: T.string,
fill: T.string,
originalW: T.number,
originalH: T.number,
pathOffsetX: T.number,
pathOffsetY: T.number,
}

getDefaultProps(): UsStateShape['props'] {
return {
w: 100,
h: 100,
name: '',
pathData: '',
fill: '#4e79a7',
originalW: 100,
originalH: 100,
pathOffsetX: 0,
pathOffsetY: 0,
}
}

override canResize() {
return true
}
override isAspectRatioLocked() {
return true
}

getGeometry(shape: UsStateShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}

override onResize(shape: UsStateShape, info: TLResizeInfo<UsStateShape>) {
return resizeBox(shape, info)
}

component(shape: UsStateShape) {
const { w, h, pathData, fill, originalW, originalH, pathOffsetX, pathOffsetY, name } =
shape.props
const scaleX = w / originalW
const scaleY = h / originalH

return (
<>
<SVGContainer>
<g transform={`scale(${scaleX}, ${scaleY}) translate(${-pathOffsetX}, ${-pathOffsetY})`}>
<path
d={pathData}
fill={fill}
stroke="#fff"
strokeWidth={1 / scaleX}
strokeLinejoin="round"
/>
</g>
</SVGContainer>
<div
className="d3-map-state-label"
style={{ pointerEvents: 'none', fontSize: `calc(11px * var(--tl-scale))` }}
>
{name}
</div>
</>
)
}

indicator(shape: UsStateShape) {
return <rect width={shape.props.w} height={shape.props.h} />
}
}
Loading
Loading