useOverlayLayer
Source:
src/hooks/useOverlayLayer.ts
useOverlayLayer is the drawing-state visualization layer. It reads the FSM's current state value (drawPolyline / drawCatmullRom / drawBezier / drawArc / drawRotatedRect / drawPolygon) plus context (drawPoints / previewPoint / bezierAnchors) and renders the per-state preview geometry into the overlay GeoJSON source (yellow dashed lines + vertices + control handles).
The same file also exports useSnapIndicatorLayer (private). It subscribes to useUIStore.currentSnapTarget and paints the snap ring into the snap source. Together they cover every "floating UI" element during drawing.
Source boundaries
| Source | Contents | Writer |
|---|---|---|
cold | All committed entities | useColdLayer (worker) |
hot | Currently selected entity + drag preview | useHotLayer |
overlay | In-progress draft geometry (drawPoints / bezier anchors) | useOverlayLayer |
snap | Snap indicator | useOverlayLayer.useSnapIndicatorLayer |
grid | Metric reference grid | useGridLayer |
Colour codes: cold uses ams-* primary; hot is blue; overlay is yellow dashed; snap is cyan ring.
Why split this way
- Drawing feedback is pure front-end with zero worker round-trip; each builder function is under 30 lines and covers one geometry shape.
- State switching → builder dispatch via
OVERLAY_BUILDERS; adding a new geometry means registering one entry. - The snap indicator is decoupled from drawing feedback: it must render during vertex drag too, so it owns its own source.
Entering / exiting isDrawingState
The isDrawingState set (editorMachine.ts) covers: drawPolyline / drawCatmullRom / drawBezier / drawArc / drawRotatedRect / drawPolygon. FSM enters them via:
SELECT_TOOLevent carryingtool: 'drawXxx'— ToolStrip clickidle → drawXxxonly throughSELECT_TOOL
Exit:
CONFIRM/DOUBLE_CLICK→idleCANCEL→idle(resetDraw clears drawPoints)SELECT_TOOLto a sibling draw tool → directly into the target draw state
Signature
function useOverlayLayer(
mapRef: React.RefObject<maplibregl.Map | null>,
mapLoadedRef: React.RefObject<boolean>,
actorRef: ActorRefFrom<typeof editorMachine>,
): void;2
3
4
5
Parameters
| Name | Type | Role |
|---|---|---|
mapRef | RefObject<maplibregl.Map | null> | MapLibre instance. |
mapLoadedRef | RefObject<boolean> | Readiness flag. |
actorRef | ActorRefFrom<typeof editorMachine> | FSM actor; subscribed to drive the RAF render. |
Returns
void. Effects target both overlay and snap sources.
OverlayRenderState
export type OverlayRenderState = {
currentState: string; // FSM value
drawPoints: LngLat[]; // confirmed points
previewPoint: LngLat | null; // cursor-following preview
bezierAnchors: BezierAnchor[]; // drawBezier only
};2
3
4
5
6
Builder table
// useOverlayLayer.ts:157-164
const OVERLAY_BUILDERS: Record<string, OverlayBuilder> = {
drawPolyline: buildPolylineFeatures,
drawCatmullRom: buildCatmullRomFeatures,
drawBezier: buildBezierFeatures,
drawArc: buildArcFeatures,
drawRotatedRect: buildRotatedRectFeatures,
drawPolygon: buildPolygonFeatures,
};2
3
4
5
6
7
8
9
| State | Preview geometry | Trigger |
|---|---|---|
drawPolyline | line chain + vertices | allPts.length >= 2 |
drawCatmullRom | Catmull-Rom curve + vertices | allPts.length >= 2 |
drawBezier | cubic bezier curve + handle dashes + control handles | withPreviewAnchors.length >= 2 |
drawArc | 3-point arc (2 points draws straight line) | allPts.length === 3 |
drawRotatedRect | rotated rectangle + axis preview | allPts.length === 3 |
drawPolygon | closed polygon (< 3 pts shows line) | allPts.length >= 3 |
Side effects
| Effect | Trigger | Cleanup |
|---|---|---|
actorRef.subscribe(scheduleRender) | Mount | subscription.unsubscribe() |
requestAnimationFrame(renderOverlayLayer) | Actor state change | cancelAnimationFrame(frameId) |
src.setData(...) | Render fires + sameOverlayRenderState returns false | — |
useUIStore.subscribe(currentSnapTarget) | useSnapIndicatorLayer mount | Returned unsub |
snap source write | Snap target changes | — |
Lifecycle
mount (overlay)
├── subscribe(actorRef) → scheduleRender
└── if mapLoaded: scheduleRender() else: map.once('load', scheduleRender)
renderOverlayLayer (RAF)
├── snapshot → OverlayRenderState
├── if sameOverlayRenderState(last, next): return
├── if !isDrawingState(currentState): setData(EMPTY_FC); return
└── setData(buildOverlayFeatures(state))
mount (snap indicator)
├── apply current currentSnapTarget once
└── subscribe(useUIStore) → apply only on currentSnapTarget change2
3
4
5
6
7
8
9
10
11
12
13
Invariants
Only write overlay during drawing states
// useOverlayLayer.ts:249-252
if (!isDrawingState(nextState.currentState)) {
src.setData(EMPTY_FC);
return;
}2
3
4
5
Switching back to idle / selected clears immediately, killing preview leftovers from the previous draw.
Bezier preview must clone anchors
// useOverlayLayer.ts:101
const runtimeAnchors: BezierAnchor[] = bezierAnchors.map((anchor) => ({ ...anchor }));2
The transient preview anchor pushed during drawing must not mutate the FSM context (XState 5 does not auto-freeze).
Snap target dedup happens in the store
// useOverlayLayer.ts:208-212
const unsub = useUIStore.subscribe((s, prev) => {
if (s.currentSnapTarget !== prev.currentSnapTarget) {
apply(s.currentSnapTarget);
}
});2
3
4
5
6
uiStore.setSnapTarget already de-dups equivalent SnapTarget values internally (see store docs); reference comparison here is sufficient.
Call site
// src/components/map/MapCanvas.tsx:37
useOverlayLayer(mapRef, mapLoadedRef, actorRef);2
MapCanvas also writes uiStore.currentSnapTarget from useMapEventRouter (applySnap), so overlay + snap indicator stay in sync inside this single mount.
Failure modes
| Symptom | Root cause | Fix |
|---|---|---|
| Preview doesn't refresh | OVERLAY_BUILDERS missing the state | Register the new state in OVERLAY_BUILDERS |
| Bezier handles overlap | bezierAnchorFeatures skipped in/out distinction | Lines 80-94 — confirm both handleIn and handleOut produce pointFeature |
| Old line lingers after switching tool | isDrawingState doesn't include the new state | Update the set in editorMachine.ts |
| Snap ring stays after toggling snap off | useMapEventRouter's store subscription didn't fire | See router lines 215-219 for the cleanup path |
See also
Frame budget
- All builders are O(n) where n is the current confirmed point count (typically < 20 mid-draw).
cubicBezierandcatmullRomuse fixed sample counts (24/segment), independent of zoom level.- The snap indicator is a single Point feature; setData cost is negligible.
Snap indicator details
// useOverlayLayer.ts:171-186
function snapTargetFeatureCollection(target: SnapTarget): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { kind: target.kind, entityId: target.entityId, entityType: target.entityType },
geometry: { type: 'Point', coordinates: [target.point.x, target.point.y] },
},
],
};
}2
3
4
5
6
7
8
9
10
11
12
13
kind: 'vertex' | 'edge'—mapLibreInit/layers.ts:248-256uses amatchexpression to colour vertex vs edge differently.entityTypeis debug-only and doesn't affect style.
Relationship with useDrawCommit
useOverlayLayer only reads FSM context for preview; persistence is handled by useDrawCommit. They don't interfere:
during draw: FSM + drawPoints → useOverlayLayer renders preview
↓ DOUBLE_CLICK / Enter
FSM transition → idle: useDrawCommit calls addEntity (POST snapshot)
↓
useOverlayLayer sees !isDrawingState → setData(EMPTY_FC) clears preview
useColdLayer sees entities change → writes to cold source2
3
4
5
6
Source map
| Concern | Lines |
|---|---|
OverlayRenderState type | useOverlayLayer.ts:21-26 |
sameOverlayRenderState | useOverlayLayer.ts:34-42 |
withPreview / vertexFeatures | useOverlayLayer.ts:50-56 |
buildPolylineFeatures | useOverlayLayer.ts:58-67 |
buildCatmullRomFeatures | useOverlayLayer.ts:69-78 |
buildBezierFeatures | useOverlayLayer.ts:96-115 |
buildArcFeatures | useOverlayLayer.ts:117-126 |
buildRotatedRectFeatures | useOverlayLayer.ts:128-144 |
buildPolygonFeatures | useOverlayLayer.ts:146-155 |
OVERLAY_BUILDERS dispatch | useOverlayLayer.ts:157-164 |
useSnapIndicatorLayer | useOverlayLayer.ts:188-217 |
useOverlayLayer main | useOverlayLayer.ts:219-285 |