WebGL Minimap (#3510)

This PR replaces our current minimap implementation with one that uses
WebGL

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
David Sheldrick 2024-04-19 14:56:55 +01:00 committed by GitHub
parent f6a2e352de
commit b5dfd81540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 743 additions and 420 deletions

View file

@ -682,6 +682,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCameraState(): "idle" | "moving"; getCameraState(): "idle" | "moving";
getCanRedo(): boolean; getCanRedo(): boolean;
getCanUndo(): boolean; getCanUndo(): boolean;
getCollaborators(): TLInstancePresence[];
getCollaboratorsOnCurrentPage(): TLInstancePresence[];
getContainer: () => HTMLElement; getContainer: () => HTMLElement;
getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined; getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined;
// @internal // @internal
@ -693,6 +695,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCurrentPageId(): TLPageId; getCurrentPageId(): TLPageId;
getCurrentPageRenderingShapesSorted(): TLShape[]; getCurrentPageRenderingShapesSorted(): TLShape[];
getCurrentPageShapeIds(): Set<TLShapeId>; getCurrentPageShapeIds(): Set<TLShapeId>;
// @internal (undocumented)
getCurrentPageShapeIdsSorted(): TLShapeId[];
getCurrentPageShapes(): TLShape[]; getCurrentPageShapes(): TLShape[];
getCurrentPageShapesSorted(): TLShape[]; getCurrentPageShapesSorted(): TLShape[];
getCurrentPageState(): TLInstancePageState; getCurrentPageState(): TLInstancePageState;

View file

@ -10059,6 +10059,86 @@
"isAbstract": false, "isAbstract": false,
"name": "getCanUndo" "name": "getCanUndo"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getCollaborators:member(1)",
"docComment": "/**\n * Returns a list of presence records for all peer collaborators. This will return the latest presence record for each connected user.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getCollaborators(): "
},
{
"kind": "Content",
"text": "import(\"@tldraw/tlschema\")."
},
{
"kind": "Reference",
"text": "TLInstancePresence",
"canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getCollaborators"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getCollaboratorsOnCurrentPage:member(1)",
"docComment": "/**\n * Returns a list of presence records for all peer collaborators on the current page. This will return the latest presence record for each connected user.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getCollaboratorsOnCurrentPage(): "
},
{
"kind": "Content",
"text": "import(\"@tldraw/tlschema\")."
},
{
"kind": "Reference",
"text": "TLInstancePresence",
"canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getCollaboratorsOnCurrentPage"
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!Editor#getContainer:member", "canonicalReference": "@tldraw/editor!Editor#getContainer:member",

View file

@ -2619,15 +2619,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
animateToUser(userId: string): this { animateToUser(userId: string): this {
const presences = this.store.query.records('instance_presence', () => ({ const presence = this.getCollaborators().find((c) => c.userId === userId)
userId: { eq: userId },
}))
const presence = [...presences.get()]
.sort((a, b) => {
return a.lastActivityTimestamp - b.lastActivityTimestamp
})
.pop()
if (!presence) return this if (!presence) return this
@ -2883,6 +2875,45 @@ export class Editor extends EventEmitter<TLEventMap> {
z: point.z ?? 0.5, z: point.z ?? 0.5,
} }
} }
// Collaborators
@computed
private _getCollaboratorsQuery() {
return this.store.query.records('instance_presence', () => ({
userId: { neq: this.user.getId() },
}))
}
/**
* Returns a list of presence records for all peer collaborators.
* This will return the latest presence record for each connected user.
*
* @public
*/
@computed
getCollaborators() {
const allPresenceRecords = this._getCollaboratorsQuery().get()
if (!allPresenceRecords.length) return EMPTY_ARRAY
const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
return userIds.map((id) => {
const latestPresence = allPresenceRecords
.filter((c) => c.userId === id)
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
return latestPresence
})
}
/**
* Returns a list of presence records for all peer collaborators on the current page.
* This will return the latest presence record for each connected user.
*
* @public
*/
@computed
getCollaboratorsOnCurrentPage() {
const currentPageId = this.getCurrentPageId()
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
}
// Following // Following
@ -2894,9 +2925,9 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
startFollowingUser(userId: string): this { startFollowingUser(userId: string): this {
const leaderPresences = this.store.query.records('instance_presence', () => ({ const leaderPresences = this._getCollaboratorsQuery()
userId: { eq: userId }, .get()
})) .filter((p) => p.userId === userId)
const thisUserId = this.user.getId() const thisUserId = this.user.getId()
@ -2905,7 +2936,7 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
// If the leader is following us, then we can't follow them // If the leader is following us, then we can't follow them
if (leaderPresences.get().some((p) => p.followingUserId === thisUserId)) { if (leaderPresences.some((p) => p.followingUserId === thisUserId)) {
return this return this
} }
@ -2924,7 +2955,7 @@ export class Editor extends EventEmitter<TLEventMap> {
const moveTowardsUser = () => { const moveTowardsUser = () => {
// Stop following if we can't find the user // Stop following if we can't find the user
const leaderPresence = [...leaderPresences.get()] const leaderPresence = [...leaderPresences]
.sort((a, b) => { .sort((a, b) => {
return a.lastActivityTimestamp - b.lastActivityTimestamp return a.lastActivityTimestamp - b.lastActivityTimestamp
}) })
@ -3281,6 +3312,14 @@ export class Editor extends EventEmitter<TLEventMap> {
return this._currentPageShapeIds.get() return this._currentPageShapeIds.get()
} }
/**
* @internal
*/
@computed
getCurrentPageShapeIdsSorted() {
return Array.from(this.getCurrentPageShapeIds()).sort()
}
/** /**
* Get the ids of shapes on a page. * Get the ids of shapes on a page.
* *
@ -3893,7 +3932,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
getShapePageTransform(shape: TLShape | TLShapeId): Mat { getShapePageTransform(shape: TLShape | TLShapeId): Mat {
const id = typeof shape === 'string' ? shape : this.getShape(shape)!.id const id = typeof shape === 'string' ? shape : shape.id
return this._getShapePageTransformCache().get(id) ?? Mat.Identity() return this._getShapePageTransformCache().get(id) ?? Mat.Identity()
} }
@ -4227,7 +4266,7 @@ export class Editor extends EventEmitter<TLEventMap> {
@computed getCurrentPageBounds(): Box | undefined { @computed getCurrentPageBounds(): Box | undefined {
let commonBounds: Box | undefined let commonBounds: Box | undefined
this.getCurrentPageShapeIds().forEach((shapeId) => { this.getCurrentPageShapeIdsSorted().forEach((shapeId) => {
const bounds = this.getShapeMaskedPageBounds(shapeId) const bounds = this.getShapeMaskedPageBounds(shapeId)
if (!bounds) return if (!bounds) return
if (!commonBounds) { if (!commonBounds) {
@ -8159,7 +8198,11 @@ export class Editor extends EventEmitter<TLEventMap> {
// it will be 0,0 when its actual screen position is equal // it will be 0,0 when its actual screen position is equal
// to screenBounds.point. This is confusing! // to screenBounds.point. This is confusing!
currentScreenPoint.set(sx, sy) currentScreenPoint.set(sx, sy)
currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz) const nx = sx / cz - cx
const ny = sy / cz - cy
if (isFinite(nx) && isFinite(ny)) {
currentPagePoint.set(nx, ny, sz)
}
this.inputs.isPen = info.type === 'pointer' && info.isPen this.inputs.isPen = info.type === 'pointer' && info.isPen

View file

@ -1,5 +1,4 @@
import { useComputed, useValue } from '@tldraw/state' import { useComputed, useValue } from '@tldraw/state'
import { useMemo } from 'react'
import { uniq } from '../utils/uniq' import { uniq } from '../utils/uniq'
import { useEditor } from './useEditor' import { useEditor } from './useEditor'
@ -10,17 +9,12 @@ import { useEditor } from './useEditor'
*/ */
export function usePeerIds() { export function usePeerIds() {
const editor = useEditor() const editor = useEditor()
const $presences = useMemo(() => {
return editor.store.query.records('instance_presence', () => ({
userId: { neq: editor.user.getId() },
}))
}, [editor])
const $userIds = useComputed( const $userIds = useComputed(
'userIds', 'userIds',
() => uniq($presences.get().map((p) => p.userId)).sort(), () => uniq(editor.getCollaborators().map((p) => p.userId)).sort(),
{ isEqual: (a, b) => a.join(',') === b.join?.(',') }, { isEqual: (a, b) => a.join(',') === b.join?.(',') },
[$presences] [editor]
) )
return useValue($userIds) return useValue($userIds)

View file

@ -1,6 +1,5 @@
import { useValue } from '@tldraw/state' import { useValue } from '@tldraw/state'
import { TLInstancePresence } from '@tldraw/tlschema' import { TLInstancePresence } from '@tldraw/tlschema'
import { useMemo } from 'react'
import { useEditor } from './useEditor' import { useEditor } from './useEditor'
// TODO: maybe move this to a computed property on the App class? // TODO: maybe move this to a computed property on the App class?
@ -11,21 +10,12 @@ import { useEditor } from './useEditor'
export function usePresence(userId: string): TLInstancePresence | null { export function usePresence(userId: string): TLInstancePresence | null {
const editor = useEditor() const editor = useEditor()
const $presences = useMemo(() => {
return editor.store.query.records('instance_presence', () => ({
userId: { eq: userId },
}))
}, [editor, userId])
const latestPresence = useValue( const latestPresence = useValue(
`latestPresence:${userId}`, `latestPresence:${userId}`,
() => { () => {
return $presences return editor.getCollaborators().find((c) => c.userId === userId)
.get()
.slice()
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
}, },
[] [editor]
) )
return latestPresence ?? null return latestPresence ?? null

View file

@ -39,12 +39,13 @@ export class Mat {
equals(m: Mat | MatModel) { equals(m: Mat | MatModel) {
return ( return (
this.a === m.a && this === m ||
(this.a === m.a &&
this.b === m.b && this.b === m.b &&
this.c === m.c && this.c === m.c &&
this.d === m.d && this.d === m.d &&
this.e === m.e && this.e === m.e &&
this.f === m.f this.f === m.f)
) )
} }

View file

@ -1,18 +1,13 @@
import { import {
ANIMATION_MEDIUM_MS, ANIMATION_MEDIUM_MS,
Box,
TLPointerEventInfo, TLPointerEventInfo,
TLShapeId,
Vec, Vec,
getPointerInfo, getPointerInfo,
intersectPolygonPolygon,
normalizeWheel, normalizeWheel,
releasePointerCapture, releasePointerCapture,
setPointerCapture, setPointerCapture,
useComputed,
useEditor, useEditor,
useIsDarkMode, useIsDarkMode,
useQuickReactor,
} from '@tldraw/editor' } from '@tldraw/editor'
import * as React from 'react' import * as React from 'react'
import { MinimapManager } from './MinimapManager' import { MinimapManager } from './MinimapManager'
@ -24,67 +19,78 @@ export function DefaultMinimap() {
const rCanvas = React.useRef<HTMLCanvasElement>(null!) const rCanvas = React.useRef<HTMLCanvasElement>(null!)
const rPointing = React.useRef(false) const rPointing = React.useRef(false)
const isDarkMode = useIsDarkMode() const minimapRef = React.useRef<MinimapManager>()
const devicePixelRatio = useComputed('dpr', () => editor.getInstanceState().devicePixelRatio, [
editor,
])
const presences = React.useMemo(() => editor.store.query.records('instance_presence'), [editor])
const minimap = React.useMemo(() => new MinimapManager(editor), [editor])
React.useEffect(() => { React.useEffect(() => {
// Must check after render const minimap = new MinimapManager(editor, rCanvas.current)
const raf = requestAnimationFrame(() => { minimapRef.current = minimap
minimap.updateColors() return minimapRef.current.close
minimap.render() }, [editor])
})
return () => {
cancelAnimationFrame(raf)
}
}, [editor, minimap, isDarkMode])
const onDoubleClick = React.useCallback( const onDoubleClick = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!editor.getCurrentPageShapeIds().size) return if (!editor.getCurrentPageShapeIds().size) return
if (!minimapRef.current) return
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const point = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,
e.clientY,
false,
false
)
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,
e.clientY,
false,
true
)
minimap.originPagePoint.setTo(clampedPoint) minimapRef.current.originPagePoint.setTo(clampedPoint)
minimap.originPageCenter.setTo(editor.getViewportPageBounds().center) minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center)
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
}, },
[editor, minimap] [editor]
) )
const onPointerDown = React.useCallback( const onPointerDown = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!minimapRef.current) return
const elm = e.currentTarget const elm = e.currentTarget
setPointerCapture(elm, e) setPointerCapture(elm, e)
if (!editor.getCurrentPageShapeIds().size) return if (!editor.getCurrentPageShapeIds().size) return
rPointing.current = true rPointing.current = true
minimap.isInViewport = false minimapRef.current.isInViewport = false
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false) const point = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,
e.clientY,
false,
false
)
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true) const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,
e.clientY,
false,
true
)
const _vpPageBounds = editor.getViewportPageBounds() const _vpPageBounds = editor.getViewportPageBounds()
minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint) minimapRef.current.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
if (minimap.isInViewport) { if (minimapRef.current.isInViewport) {
minimap.originPagePoint.setTo(clampedPoint) minimapRef.current.originPagePoint.setTo(clampedPoint)
minimap.originPageCenter.setTo(_vpPageBounds.center) minimapRef.current.originPageCenter.setTo(_vpPageBounds.center)
} else { } else {
const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point) const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point)
const pagePoint = Vec.Add(point, delta) const pagePoint = Vec.Add(point, delta)
minimap.originPagePoint.setTo(pagePoint) minimapRef.current.originPagePoint.setTo(pagePoint)
minimap.originPageCenter.setTo(point) minimapRef.current.originPageCenter.setTo(point)
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS }) editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
} }
@ -98,16 +104,24 @@ export function DefaultMinimap() {
document.body.addEventListener('pointerup', release) document.body.addEventListener('pointerup', release)
}, },
[editor, minimap] [editor]
) )
const onPointerMove = React.useCallback( const onPointerMove = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true) if (!minimapRef.current) return
const point = minimapRef.current.minimapScreenPointToPagePoint(
e.clientX,
e.clientY,
e.shiftKey,
true
)
if (rPointing.current) { if (rPointing.current) {
if (minimap.isInViewport) { if (minimapRef.current.isInViewport) {
const delta = minimap.originPagePoint.clone().sub(minimap.originPageCenter) const delta = minimapRef.current.originPagePoint
.clone()
.sub(minimapRef.current.originPageCenter)
editor.centerOnPoint(Vec.Sub(point, delta)) editor.centerOnPoint(Vec.Sub(point, delta))
return return
} }
@ -115,7 +129,7 @@ export function DefaultMinimap() {
editor.centerOnPoint(point) editor.centerOnPoint(point)
} }
const pagePoint = minimap.getPagePoint(e.clientX, e.clientY) const pagePoint = minimapRef.current.getPagePoint(e.clientX, e.clientY)
const screenPoint = editor.pageToScreen(pagePoint) const screenPoint = editor.pageToScreen(pagePoint)
@ -130,7 +144,7 @@ export function DefaultMinimap() {
editor.dispatch(info) editor.dispatch(info)
}, },
[editor, minimap] [editor]
) )
const onWheel = React.useCallback( const onWheel = React.useCallback(
@ -150,73 +164,16 @@ export function DefaultMinimap() {
[editor] [editor]
) )
// Update the minimap's dpr when the dpr changes const isDarkMode = useIsDarkMode()
useQuickReactor(
'update when dpr changes',
() => {
const dpr = devicePixelRatio.get()
minimap.setDpr(dpr)
const canvas = rCanvas.current as HTMLCanvasElement React.useEffect(() => {
const rect = canvas.getBoundingClientRect() // need to wait a tick for next theme css to be applied
const width = rect.width * dpr // otherwise the minimap will render with the wrong colors
const height = rect.height * dpr setTimeout(() => {
minimapRef.current?.updateColors()
// These must happen in order minimapRef.current?.render()
canvas.width = width
canvas.height = height
minimap.canvasScreenBounds.set(rect.x, rect.y, width, height)
minimap.cvs = rCanvas.current
},
[devicePixelRatio, minimap]
)
useQuickReactor(
'minimap render when pagebounds or collaborators changes',
() => {
const shapeIdsOnCurrentPage = editor.getCurrentPageShapeIds()
const commonBoundsOfAllShapesOnCurrentPage = editor.getCurrentPageBounds()
const viewportPageBounds = editor.getViewportPageBounds()
const _dpr = devicePixelRatio.get() // dereference
minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage
? Box.Expand(commonBoundsOfAllShapesOnCurrentPage, viewportPageBounds)
: viewportPageBounds
minimap.updateContentScreenBounds()
// All shape bounds
const allShapeBounds = [] as (Box & { id: TLShapeId })[]
shapeIdsOnCurrentPage.forEach((id) => {
let pageBounds = editor.getShapePageBounds(id) as Box & { id: TLShapeId }
if (!pageBounds) return
const pageMask = editor.getShapeMask(id)
if (pageMask) {
const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners)
if (!intersection) {
return
}
pageBounds = Box.FromPoints(intersection) as Box & { id: TLShapeId }
}
if (pageBounds) {
pageBounds.id = id // kinda dirty but we want to include the id here
allShapeBounds.push(pageBounds)
}
}) })
}, [isDarkMode])
minimap.pageBounds = allShapeBounds
minimap.collaborators = presences.get()
minimap.render()
},
[editor, minimap]
)
return ( return (
<div className="tlui-minimap"> <div className="tlui-minimap">

View file

@ -1,114 +1,159 @@
import { import {
Box, Box,
ComputedCache,
Editor, Editor,
PI2, TLShape,
TLInstancePresence,
TLShapeId,
Vec, Vec,
atom,
clamp, clamp,
computed,
react,
uniqueId, uniqueId,
} from '@tldraw/editor' } from '@tldraw/editor'
import { getRgba } from './getRgba'
import { BufferStuff, appendVertices, setupWebGl } from './minimap-webgl-setup'
import { pie, rectangle, roundedRectangle } from './minimap-webgl-shapes'
export class MinimapManager { export class MinimapManager {
constructor(public editor: Editor) {} disposables = [] as (() => void)[]
close = () => this.disposables.forEach((d) => d())
dpr = 1 gl: ReturnType<typeof setupWebGl>
shapeGeometryCache: ComputedCache<Float32Array | null, TLShape>
colors = { constructor(
shapeFill: 'rgba(144, 144, 144, .1)', public editor: Editor,
selectFill: '#2f80ed', public readonly elem: HTMLCanvasElement
viewportFill: 'rgba(144, 144, 144, .1)', ) {
this.gl = setupWebGl(elem)
this.shapeGeometryCache = editor.store.createComputedCache('webgl-geometry', (r: TLShape) => {
const bounds = editor.getShapeMaskedPageBounds(r.id)
if (!bounds) return null
const arr = new Float32Array(12)
rectangle(arr, 0, bounds.x, bounds.y, bounds.w, bounds.h)
return arr
})
this.colors = this._getColors()
this.disposables.push(this._listenForCanvasResize(), react('minimap render', this.render))
} }
id = uniqueId() private _getColors() {
cvs: HTMLCanvasElement | null = null const style = getComputedStyle(this.editor.getContainer())
pageBounds: (Box & { id: TLShapeId })[] = []
collaborators: TLInstancePresence[] = []
canvasScreenBounds = new Box() return {
canvasPageBounds = new Box() shapeFill: getRgba(style.getPropertyValue('--color-text-3').trim()),
selectFill: getRgba(style.getPropertyValue('--color-selected').trim()),
viewportFill: getRgba(style.getPropertyValue('--color-muted-1').trim()),
}
}
contentPageBounds = new Box() private colors: ReturnType<MinimapManager['_getColors']>
contentScreenBounds = new Box() // this should be called after dark/light mode changes have propagated to the dom
updateColors() {
this.colors = this._getColors()
}
readonly id = uniqueId()
@computed
getDpr() {
return this.editor.getInstanceState().devicePixelRatio
}
@computed
getContentPageBounds() {
const viewportPageBounds = this.editor.getViewportPageBounds()
const commonShapeBounds = this.editor.getCurrentPageBounds()
return commonShapeBounds
? Box.Expand(commonShapeBounds, viewportPageBounds)
: viewportPageBounds
}
@computed
getContentScreenBounds() {
const contentPageBounds = this.getContentPageBounds()
const topLeft = this.editor.pageToScreen(contentPageBounds.point)
const bottomRight = this.editor.pageToScreen(
new Vec(contentPageBounds.maxX, contentPageBounds.maxY)
)
return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y)
}
private _getCanvasBoundingRect() {
const { x, y, width, height } = this.elem.getBoundingClientRect()
return new Box(x, y, width, height)
}
private readonly canvasBoundingClientRect = atom('canvasBoundingClientRect', new Box())
getCanvasScreenBounds() {
return this.canvasBoundingClientRect.get()
}
private _listenForCanvasResize() {
const observer = new ResizeObserver(() => {
const rect = this._getCanvasBoundingRect()
this.canvasBoundingClientRect.set(rect)
})
observer.observe(this.elem)
return () => observer.disconnect()
}
@computed
getCanvasSize() {
const rect = this.canvasBoundingClientRect.get()
const dpr = this.getDpr()
return new Vec(rect.width * dpr, rect.height * dpr)
}
@computed
getCanvasClientPosition() {
return this.canvasBoundingClientRect.get().point
}
originPagePoint = new Vec() originPagePoint = new Vec()
originPageCenter = new Vec() originPageCenter = new Vec()
isInViewport = false isInViewport = false
debug = false
setDpr(dpr: number) {
this.dpr = +dpr.toFixed(2)
}
updateContentScreenBounds = () => {
const { contentScreenBounds, contentPageBounds: content, canvasScreenBounds: canvas } = this
let { x, y, w, h } = contentScreenBounds
if (content.w > content.h) {
const sh = canvas.w / (content.w / content.h)
if (sh > canvas.h) {
x = (canvas.w - canvas.w * (canvas.h / sh)) / 2
y = 0
w = canvas.w * (canvas.h / sh)
h = canvas.h
} else {
x = 0
y = (canvas.h - sh) / 2
w = canvas.w
h = sh
}
} else if (content.w < content.h) {
const sw = canvas.h / (content.h / content.w)
x = (canvas.w - sw) / 2
y = 0
w = sw
h = canvas.h
} else {
x = canvas.h / 2
y = 0
w = canvas.h
h = canvas.h
}
contentScreenBounds.set(x, y, w, h)
}
/** Get the canvas's true bounds converted to page bounds. */ /** Get the canvas's true bounds converted to page bounds. */
updateCanvasPageBounds = () => { @computed getCanvasPageBounds() {
const { canvasPageBounds, canvasScreenBounds, contentPageBounds, contentScreenBounds } = this const canvasScreenBounds = this.getCanvasScreenBounds()
const contentPageBounds = this.getContentPageBounds()
canvasPageBounds.set( const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height
0,
0,
contentPageBounds.width / (contentScreenBounds.width / canvasScreenBounds.width),
contentPageBounds.height / (contentScreenBounds.height / canvasScreenBounds.height)
)
canvasPageBounds.center = contentPageBounds.center let targetWidth = contentPageBounds.width
let targetHeight = targetWidth / aspectRatio
if (targetHeight < contentPageBounds.height) {
targetHeight = contentPageBounds.height
targetWidth = targetHeight * aspectRatio
} }
getScreenPoint = (x: number, y: number) => { const box = new Box(0, 0, targetWidth, targetHeight)
const { canvasScreenBounds } = this box.center = contentPageBounds.center
return box
const screenX = (x - canvasScreenBounds.minX) * this.dpr
const screenY = (y - canvasScreenBounds.minY) * this.dpr
return { x: screenX, y: screenY }
} }
getPagePoint = (x: number, y: number) => { @computed getCanvasPageBoundsArray() {
const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this const { x, y, w, h } = this.getCanvasPageBounds()
return new Float32Array([x, y, w, h])
}
const { x: screenX, y: screenY } = this.getScreenPoint(x, y) getPagePoint = (clientX: number, clientY: number) => {
const canvasPageBounds = this.getCanvasPageBounds()
const canvasScreenBounds = this.getCanvasScreenBounds()
return new Vec( // first offset the canvas position
canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width, let x = clientX - canvasScreenBounds.x
canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height, let y = clientY - canvasScreenBounds.y
1
) // then multiply by the ratio between the page and screen bounds
x *= canvasPageBounds.width / canvasScreenBounds.width
y *= canvasPageBounds.height / canvasScreenBounds.height
// then add the canvas page bounds' offset
x += canvasPageBounds.minX
y += canvasPageBounds.minY
return new Vec(x, y, 1)
} }
minimapScreenPointToPagePoint = ( minimapScreenPointToPagePoint = (
@ -123,13 +168,13 @@ export class MinimapManager {
let { x: px, y: py } = this.getPagePoint(x, y) let { x: px, y: py } = this.getPagePoint(x, y)
if (clampToBounds) { if (clampToBounds) {
const shapesPageBounds = this.editor.getCurrentPageBounds() const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box()
const vpPageBounds = viewportPageBounds const vpPageBounds = viewportPageBounds
const minX = (shapesPageBounds?.minX ?? 0) - vpPageBounds.width / 2 const minX = shapesPageBounds.minX - vpPageBounds.width / 2
const maxX = (shapesPageBounds?.maxX ?? 0) + vpPageBounds.width / 2 const maxX = shapesPageBounds.maxX + vpPageBounds.width / 2
const minY = (shapesPageBounds?.minY ?? 0) - vpPageBounds.height / 2 const minY = shapesPageBounds.minY - vpPageBounds.height / 2
const maxY = (shapesPageBounds?.maxY ?? 0) + vpPageBounds.height / 2 const maxY = shapesPageBounds.maxY + vpPageBounds.height / 2
const lx = Math.max(0, minX + vpPageBounds.width - px) const lx = Math.max(0, minX + vpPageBounds.width - px)
const rx = Math.max(0, -(maxX - vpPageBounds.width - px)) const rx = Math.max(0, -(maxX - vpPageBounds.width - px))
@ -171,209 +216,110 @@ export class MinimapManager {
return new Vec(px, py) return new Vec(px, py)
} }
updateColors = () => {
const style = getComputedStyle(this.editor.getContainer())
this.colors = {
shapeFill: style.getPropertyValue('--color-text-3').trim(),
selectFill: style.getPropertyValue('--color-selected').trim(),
viewportFill: style.getPropertyValue('--color-muted-1').trim(),
}
}
render = () => { render = () => {
const { cvs, pageBounds } = this // make sure we update when dark mode switches
this.updateCanvasPageBounds() const context = this.gl.context
const canvasSize = this.getCanvasSize()
const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } = this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray())
this
const { width: cw, height: ch } = canvasScreenBounds
const selectedShapeIds = new Set(editor.getSelectedShapeIds()) this.elem.width = canvasSize.x
const viewportPageBounds = editor.getViewportPageBounds() this.elem.height = canvasSize.y
context.viewport(0, 0, canvasSize.x, canvasSize.y)
if (!cvs || !pageBounds) { // this affects which color transparent shapes are blended with
return // during rendering. If we were to invert this any shapes narrower
// than 1 px in screen space would have much lower contrast. e.g.
// draw shapes on a large canvas.
if (this.editor.user.getIsDarkMode()) {
context.clearColor(1, 1, 1, 0)
} else {
context.clearColor(0, 0, 0, 0)
} }
const ctx = cvs.getContext('2d')! context.clear(context.COLOR_BUFFER_BIT)
if (!ctx) { const selectedShapes = new Set(this.editor.getSelectedShapeIds())
throw new Error('Minimap (shapes): Could not get context')
}
ctx.resetTransform() const colors = this.colors
ctx.globalAlpha = 1 let selectedShapeOffset = 0
ctx.clearRect(0, 0, cw, ch) let unselectedShapeOffset = 0
// Transform canvas const ids = this.editor.getCurrentPageShapeIdsSorted()
const sx = contentScreenBounds.width / contentPageBounds.width for (let i = 0, len = ids.length; i < len; i++) {
const sy = contentScreenBounds.height / contentPageBounds.height const shapeId = ids[i]
const geometry = this.shapeGeometryCache.get(shapeId)
if (!geometry) continue
ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2) const len = geometry.length
ctx.scale(sx, sy)
ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY)
// shapes if (selectedShapes.has(shapeId)) {
const shapesPath = new Path2D() appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry)
const selectedPath = new Path2D() selectedShapeOffset += len
} else {
const { shapeFill, selectFill, viewportFill } = this.colors appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry)
unselectedShapeOffset += len
// When there are many shapes, don't draw rounded rectangles;
// consider using the shape's size instead.
let pb: Box & { id: TLShapeId }
for (let i = 0, n = pageBounds.length; i < n; i++) {
pb = pageBounds[i]
;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect(
pb.minX,
pb.minY,
pb.width,
pb.height
)
}
// Fill the shapes paths
ctx.fillStyle = shapeFill
ctx.fill(shapesPath)
// Fill the selected paths
ctx.fillStyle = selectFill
ctx.fill(selectedPath)
if (this.debug) {
// Page bounds
const commonBounds = Box.Common(pageBounds)
const { minX, minY, width, height } = commonBounds
ctx.strokeStyle = 'green'
ctx.lineWidth = 2 / sx
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy)
}
// Brush
{
const { brush } = editor.getInstanceState()
if (brush) {
const { x, y, w, h } = brush
ctx.beginPath()
MinimapManager.sharpRect(ctx, x, y, w, h)
ctx.closePath()
ctx.fillStyle = viewportFill
ctx.fill()
} }
} }
// Viewport this.drawViewport()
{ this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill)
const { minX, minY, width, height } = viewportPageBounds this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill)
this.drawCollaborators()
ctx.beginPath()
const rx = 12 / sx
const ry = 12 / sx
MinimapManager.roundedRect(
ctx,
minX,
minY,
width,
height,
Math.min(width / 4, rx),
Math.min(height / 4, ry)
)
ctx.closePath()
ctx.fillStyle = viewportFill
ctx.fill()
if (this.debug) {
ctx.strokeStyle = 'orange'
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy)
}
} }
// Show collaborator cursors private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) {
this.gl.prepareTriangles(stuff, len)
// Padding for canvas bounds edges this.gl.setFillColor(color)
const px = 2.5 / sx this.gl.drawTriangles(len)
const py = 2.5 / sy
const currentPageId = editor.getCurrentPageId()
let collaborator: TLInstancePresence
for (let i = 0; i < this.collaborators.length; i++) {
collaborator = this.collaborators[i]
if (collaborator.currentPageId !== currentPageId) {
continue
} }
ctx.beginPath() private drawViewport() {
ctx.ellipse( const viewport = this.editor.getViewportPageBounds()
clamp(collaborator.cursor.x, canvasPageBounds.minX + px, canvasPageBounds.maxX - px), const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width
clamp(collaborator.cursor.y, canvasPageBounds.minY + py, canvasPageBounds.maxY - py), const len = roundedRectangle(this.gl.viewport.vertices, viewport, 4 * zoom)
5 / sx,
5 / sy, this.gl.prepareTriangles(this.gl.viewport, len)
0, this.gl.setFillColor(this.colors.viewportFill)
0, this.gl.drawTriangles(len)
PI2
)
ctx.fillStyle = collaborator.color
ctx.fill()
} }
if (this.debug) { drawCollaborators() {
ctx.lineWidth = 2 / sx const collaborators = this.editor.getCollaboratorsOnCurrentPage()
if (!collaborators.length) return
{ const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width
// Minimap Bounds
const { minX, minY, width, height } = contentPageBounds // just draw a little circle for each collaborator
ctx.strokeStyle = 'red' const numSegmentsPerCircle = 20
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) const dataSizePerCircle = numSegmentsPerCircle * 6
const totalSize = dataSizePerCircle * collaborators.length
// expand vertex array if needed
if (this.gl.collaborators.vertices.length < totalSize) {
this.gl.collaborators.vertices = new Float32Array(totalSize)
} }
{ const vertices = this.gl.collaborators.vertices
// Canvas Bounds let offset = 0
const { minX, minY, width, height } = canvasPageBounds for (const { cursor } of collaborators) {
ctx.strokeStyle = 'blue' pie(vertices, {
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) center: Vec.From(cursor),
radius: 2 * zoom,
offset,
numArcSegments: numSegmentsPerCircle,
})
offset += dataSizePerCircle
}
this.gl.prepareTriangles(this.gl.collaborators, totalSize)
offset = 0
for (const { color } of collaborators) {
this.gl.setFillColor(getRgba(color))
this.gl.context.drawArrays(this.gl.context.TRIANGLES, offset / 2, dataSizePerCircle / 2)
offset += dataSizePerCircle
} }
} }
} }
static roundedRect(
ctx: CanvasRenderingContext2D | Path2D,
x: number,
y: number,
width: number,
height: number,
rx: number,
ry: number
) {
if (rx < 1 && ry < 1) {
ctx.rect(x, y, width, height)
return
}
ctx.moveTo(x + rx, y)
ctx.lineTo(x + width - rx, y)
ctx.quadraticCurveTo(x + width, y, x + width, y + ry)
ctx.lineTo(x + width, y + height - ry)
ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height)
ctx.lineTo(x + rx, y + height)
ctx.quadraticCurveTo(x, y + height, x, y + height - ry)
ctx.lineTo(x, y + ry)
ctx.quadraticCurveTo(x, y, x + rx, y)
}
static sharpRect(
ctx: CanvasRenderingContext2D | Path2D,
x: number,
y: number,
width: number,
height: number,
_rx?: number,
_ry?: number
) {
ctx.rect(x, y, width, height)
}
}

View file

@ -0,0 +1,16 @@
const memo = {} as Record<string, Float32Array>
export function getRgba(colorString: string) {
if (memo[colorString]) {
return memo[colorString]
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
context!.fillStyle = colorString
context!.fillRect(0, 0, 1, 1)
const [r, g, b, a] = context!.getImageData(0, 0, 1, 1).data
const result = new Float32Array([r / 255, g / 255, b / 255, a / 255])
memo[colorString] = result
return result
}

View file

@ -0,0 +1,148 @@
import { roundedRectangleDataSize } from './minimap-webgl-shapes'
export function setupWebGl(canvas: HTMLCanvasElement | null) {
if (!canvas) throw new Error('Canvas element not found')
const context = canvas.getContext('webgl2', {
premultipliedAlpha: false,
})
if (!context) throw new Error('Failed to get webgl2 context')
const vertexShaderSourceCode = `#version 300 es
precision mediump float;
in vec2 shapeVertexPosition;
uniform vec4 canvasPageBounds;
// taken (with thanks) from
// https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html
void main() {
// convert the position from pixels to 0.0 to 1.0
vec2 zeroToOne = (shapeVertexPosition - canvasPageBounds.xy) / canvasPageBounds.zw;
// convert from 0->1 to 0->2
vec2 zeroToTwo = zeroToOne * 2.0;
// convert from 0->2 to -1->+1 (clipspace)
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
}`
const vertexShader = context.createShader(context.VERTEX_SHADER)
if (!vertexShader) {
throw new Error('Failed to create vertex shader')
}
context.shaderSource(vertexShader, vertexShaderSourceCode)
context.compileShader(vertexShader)
if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) {
throw new Error('Failed to compile vertex shader')
}
const fragmentShaderSourceCode = `#version 300 es
precision mediump float;
uniform vec4 fillColor;
out vec4 outputColor;
void main() {
outputColor = fillColor;
}`
const fragmentShader = context.createShader(context.FRAGMENT_SHADER)
if (!fragmentShader) {
throw new Error('Failed to create fragment shader')
}
context.shaderSource(fragmentShader, fragmentShaderSourceCode)
context.compileShader(fragmentShader)
if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) {
throw new Error('Failed to compile fragment shader')
}
const program = context.createProgram()
if (!program) {
throw new Error('Failed to create program')
}
context.attachShader(program, vertexShader)
context.attachShader(program, fragmentShader)
context.linkProgram(program)
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
throw new Error('Failed to link program')
}
context.useProgram(program)
const shapeVertexPositionAttributeLocation = context.getAttribLocation(
program,
'shapeVertexPosition'
)
if (shapeVertexPositionAttributeLocation < 0) {
throw new Error('Failed to get shapeVertexPosition attribute location')
}
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
const canvasPageBoundsLocation = context.getUniformLocation(program, 'canvasPageBounds')
const fillColorLocation = context.getUniformLocation(program, 'fillColor')
const selectedShapesBuffer = context.createBuffer()
if (!selectedShapesBuffer) throw new Error('Failed to create buffer')
const unselectedShapesBuffer = context.createBuffer()
if (!unselectedShapesBuffer) throw new Error('Failed to create buffer')
return {
context,
selectedShapes: allocateBuffer(context, 1024),
unselectedShapes: allocateBuffer(context, 4096),
viewport: allocateBuffer(context, roundedRectangleDataSize),
collaborators: allocateBuffer(context, 1024),
prepareTriangles(stuff: BufferStuff, len: number) {
context.bindBuffer(context.ARRAY_BUFFER, stuff.buffer)
context.bufferData(context.ARRAY_BUFFER, stuff.vertices, context.STATIC_DRAW, 0, len)
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
context.vertexAttribPointer(
shapeVertexPositionAttributeLocation,
2,
context.FLOAT,
false,
0,
0
)
},
drawTriangles(len: number) {
context.drawArrays(context.TRIANGLES, 0, len / 2)
},
setFillColor(color: Float32Array) {
context.uniform4fv(fillColorLocation, color)
},
setCanvasPageBounds(bounds: Float32Array) {
context.uniform4fv(canvasPageBoundsLocation, bounds)
},
}
}
export type BufferStuff = ReturnType<typeof allocateBuffer>
function allocateBuffer(context: WebGL2RenderingContext, size: number) {
const buffer = context.createBuffer()
if (!buffer) throw new Error('Failed to create buffer')
return { buffer, vertices: new Float32Array(size) }
}
export function appendVertices(bufferStuff: BufferStuff, offset: number, data: Float32Array) {
let len = bufferStuff.vertices.length
while (len < offset + data.length) {
len *= 2
}
if (len != bufferStuff.vertices.length) {
const newVertices = new Float32Array(len)
newVertices.set(bufferStuff.vertices)
bufferStuff.vertices = newVertices
}
bufferStuff.vertices.set(data, offset)
}

View file

@ -0,0 +1,144 @@
import { Box, HALF_PI, PI, PI2, Vec } from '@tldraw/editor'
export const numArcSegmentsPerCorner = 10
export const roundedRectangleDataSize =
// num triangles in corners
4 * 6 * numArcSegmentsPerCorner +
// num triangles in center rect
12 +
// num triangles in outer rects
4 * 12
export function pie(
array: Float32Array,
{
center,
radius,
numArcSegments = 20,
startAngle = 0,
endAngle = PI2,
offset = 0,
}: {
center: Vec
radius: number
numArcSegments?: number
startAngle?: number
endAngle?: number
offset?: number
}
) {
const angle = (endAngle - startAngle) / numArcSegments
let i = offset
for (let a = startAngle; a < endAngle; a += angle) {
array[i++] = center.x
array[i++] = center.y
array[i++] = center.x + Math.cos(a) * radius
array[i++] = center.y + Math.sin(a) * radius
array[i++] = center.x + Math.cos(a + angle) * radius
array[i++] = center.y + Math.sin(a + angle) * radius
}
return array
}
/** @internal **/
export function rectangle(
array: Float32Array,
offset: number,
x: number,
y: number,
w: number,
h: number
) {
array[offset++] = x
array[offset++] = y
array[offset++] = x
array[offset++] = y + h
array[offset++] = x + w
array[offset++] = y
array[offset++] = x + w
array[offset++] = y
array[offset++] = x
array[offset++] = y + h
array[offset++] = x + w
array[offset++] = y + h
}
export function roundedRectangle(data: Float32Array, box: Box, radius: number): number {
const numArcSegments = numArcSegmentsPerCorner
radius = Math.min(radius, Math.min(box.w, box.h) / 2)
// first draw the inner box
const innerBox = Box.ExpandBy(box, -radius)
if (innerBox.w <= 0 || innerBox.h <= 0) {
// just draw a circle
pie(data, { center: box.center, radius: radius, numArcSegments: numArcSegmentsPerCorner * 4 })
return numArcSegmentsPerCorner * 4 * 6
}
let offset = 0
// draw center rect first
rectangle(data, offset, innerBox.minX, innerBox.minY, innerBox.w, innerBox.h)
offset += 12
// then top rect
rectangle(data, offset, innerBox.minX, box.minY, innerBox.w, radius)
offset += 12
// then right rect
rectangle(data, offset, innerBox.maxX, innerBox.minY, radius, innerBox.h)
offset += 12
// then bottom rect
rectangle(data, offset, innerBox.minX, innerBox.maxY, innerBox.w, radius)
offset += 12
// then left rect
rectangle(data, offset, box.minX, innerBox.minY, radius, innerBox.h)
offset += 12
// draw the corners
// top left
pie(data, {
numArcSegments,
offset,
center: innerBox.point,
radius,
startAngle: PI,
endAngle: PI * 1.5,
})
offset += numArcSegments * 6
// top right
pie(data, {
numArcSegments,
offset,
center: Vec.Add(innerBox.point, new Vec(innerBox.w, 0)),
radius,
startAngle: PI * 1.5,
endAngle: PI2,
})
offset += numArcSegments * 6
// bottom right
pie(data, {
numArcSegments,
offset,
center: Vec.Add(innerBox.point, innerBox.size),
radius,
startAngle: 0,
endAngle: HALF_PI,
})
offset += numArcSegments * 6
// bottom left
pie(data, {
numArcSegments,
offset,
center: Vec.Add(innerBox.point, new Vec(0, innerBox.h)),
radius,
startAngle: HALF_PI,
endAngle: PI,
})
return roundedRectangleDataSize
}