entityOps — Apollo proto anti-corruption layer
Source:
src/lib/entityOps.ts(barrel) plussrc/lib/entityOps/{edit,typeGuards,cascadeDeleteRefs,reparent}.ts
Purpose
entityOps is the only public surface for proto-aware operations on map entities (see ARCHITECTURE.md "Anti-corruption layer (R2)"). Apollo proto types live in src/types/apollo.ts; the geometry compiler lives in src/core/geometry/apolloCompile.ts. Without this insulation a proto-v2 upgrade would cascade through every UI file that touches ApolloEntity.
UI code imports @/lib/entityOps, never @/core/geometry/apolloCompile directly.
Audit before each refactor:
git grep "from '@/core/geometry/apolloCompile'" -- 'src/components/**' 'src/hooks/**'A non-empty result means a new leak — fix it before merging.
Module layout
entityOps.ts ← public barrel; only re-exports
├── entityOps/edit.ts ← getEditPoints/setEditPoint/createEntity/...
├── entityOps/typeGuards.ts ← isApolloEntityType/isAreaEntity/...
├── entityOps/cascadeDeleteRefs.ts ← clean foreign keys on delete
└── entityOps/reparent.ts ← lane/road/rsu reparenting2
3
4
5
The barrel is 12 re-export lines; logic lives in the four sub-modules.
Public API at a glance
Edit (entityOps/edit.ts)
| Symbol | Kind | Signature | Summary |
|---|---|---|---|
getEditPoints | fn | (entity: MapEntity) => GeoPoint[] | Draggable control points |
setEditPoint | fn | (entity, index, point) => MapEntity | Edit one point + re-derive |
setAllEditPoints | fn | (entity, points: GeoPoint[]) => MapEntity | Replace all points + re-derive |
moveEntity | fn | (entity, dx, dy) => MapEntity | Translate entity |
deleteVertex | fn | (entity, index) => MapEntity | null | Remove a vertex; null = entity must be deleted |
compileEntity | fn | (entity) => GeoJSON.Feature[] | Cold-layer features |
createEntity | fn | (elementType, drawTool, points, anchors, options?) => ApolloEntity | FSM CONFIRM hand-off |
entityCoords | fn | (entity) => LngLat[] | "Spine" coordinates for hitTest |
Type guards (entityOps/typeGuards.ts)
| Symbol | Kind | Signature | Summary |
|---|---|---|---|
isDrawingEntity | fn | (entity) => entity is DrawingEntity | Is a drawing primitive |
isApolloEntityType | fn | (entity) => entity is ApolloEntity | Is an Apollo entity |
isAreaEntity | fn | (entity) => boolean | Counts as a region for hitTest |
isPolygonEditEntity | fn | (entity) => boolean | editPoints form a closed ring (hot layer) |
Cascade delete (entityOps/cascadeDeleteRefs.ts)
| Symbol | Kind | Signature | Summary |
|---|---|---|---|
cascadeDeleteRefsFull | fn | (removedIds, allEntities) => CascadeDeleteResult | Full result (changes + cascadeRemoved) |
CascadeDeleteResult | type | {changes, cascadeRemoved} | Patch + extra removals |
Reparent (entityOps/reparent.ts)
| Symbol | Kind | Signature | Summary |
|---|---|---|---|
ParentTarget | union | see below | junction | road | roadSection | none |
ReparentResult | interface | {changes, rejected?} | Patch + rejection reason |
reparent | fn | (child, target, allEntities) => ReparentResult | Apply reparent |
canReparent | fn | (child, target, allEntities) => boolean | Dry-run for drag-over UI |
Detailed entries
getEditPoints(entity)
function getEditPoints(entity: MapEntity): GeoPoint[];Per entity type:
- Drawing primitive (polyline / catmullRom / polygon) →
entity.points - bezier →
entity.anchors.map(a => a.point) - arc →
[start, mid, end] - rect →
[p1, p2] - Apollo entity → delegated to
getApolloEditPoints(per-type branch)
Source: entityOps/edit.ts:19-35.
setEditPoint(entity, index, point) / setAllEditPoints
Apollo entities only — drawing primitives go through the FSM in-flight buffer and never enter the store mid-draw.
Each edit calls applyDerive(next, { cause: 'editGeometry', prev: entity }) to re-run derive rules (e.g. lane.length, leftSamples resampling) while honouring _userOverrides.
Source: entityOps/edit.ts:37-51.
moveEntity(entity, dx, dy)
Translates every vertex by (dx, dy) (degrees of longitude/latitude) and re-derives. Caller maps screen-pixel drag to degrees.
deleteVertex(entity, index)
Apollo only. Returns null when the entity collapses below its minimum point count (e.g. lane centre line < 2 points). The store treats null as a signal to remove the entity entirely.
compileEntity(entity)
Compiles one entity to its cold-layer feature list — for a lane: centre line + boundaries + arrows + ID label. Drawing primitives return [] (the hot layer renders them directly).
createEntity(elementType, drawTool, points, anchors, options?)
Called by useDrawCommit after a CONFIRM transition:
createEntity(elementType, drawTool, drawPoints, drawAnchors, { laneHalfWidth, entities });The returned entity has already passed through applyDerive(_, { cause: 'create' }), so all derived fields are populated.
Source: entityOps/edit.ts:76-85.
entityCoords(entity)
The entity's "spine" — lane → centerline coordinates; rect → the four corners; polygon → ring. Used by hitTest and tooltips.
cascadeDeleteRefsFull(removedIds, allEntities)
When entities are deleted, two cleanups must happen:
- Strip stale foreign keys on remaining entities (
predecessorIds,successorIds,overlapIds,junctionId,road.sections[].laneIds, …). - Remove now-meaningless Overlap entities (where
objects.length < 2after filtering).
interface CascadeDeleteResult {
changes: Map<string, MapEntity>; // patches
cascadeRemoved: Set<string>; // extra ids to delete (orphan overlaps)
}2
3
4
Algorithm:
- Iterate
allEntities; for each, callpatchOne(e, removed, cascadeRemoved). patchOnedispatches byentityTypetocleanupLane/cleanupRoad/cleanupPNCJunction/decideOverlap.- If an Overlap survives with fewer than 2 objects, it is added to
cascadeRemoved. - A second pass cleans
overlapIdson remaining entities to remove now-orphan-overlap references.
Source: entityOps/cascadeDeleteRefs.ts:135-154.
reparent(child, target, allEntities) / ParentTarget
type ParentTarget =
| { kind: 'junction'; id: string }
| { kind: 'road'; id: string }
| { kind: 'roadSection'; roadId: string; sectionId: string }
| { kind: 'none' };2
3
4
5
Valid (child, target) pairs live in HANDLERS:
| child | target | handler |
|---|---|---|
lane | junction | handleLaneToJunction |
lane | road | handleLaneToRoad |
lane | roadSection | handleLaneToRoadSection |
lane | none | handleLaneToNone |
road | junction | handleRoadToJunction |
road | none | handleRoadToNone |
rsu | junction | handleRsuToJunction |
rsu | none | handleRsuToNone |
Invalid combinations return { changes: Map(), rejected: '...' }.
Return shape:
interface ReparentResult {
changes: Map<string, MapEntity>; // patches for child + old parent + new parent
rejected?: string; // human-readable reason
}2
3
4
Lane → Road (implicit RoadSection)
function handleLaneToRoad(child, target, allEntities): ReparentResult {
const road = allEntities.get(target.id);
if (road?.entityType !== 'road') return rejected('target is not a road');
const sectionId = road.sections[0]?.id ?? `${road.id}_s0`;
return reparent(child, { kind: 'roadSection', roadId: road.id, sectionId }, allEntities);
}2
3
4
5
6
Drop a lane onto a road = drop onto its first section. If the road has no section, synthesise ${roadId}_s0.
Lane → RoadSection (the busy path)
handleLaneToRoadSection does the most work:
- If the lane was in a junction, clear
junctionId. - Push lane id into the target section's
laneIds(skip if already there). - Remove lane id from any other section in the same road.
- Remove lane id from sections of other roads (cross-road move).
- If the target sectionId is missing, append a fresh one.
Source: entityOps/reparent.ts:82-130.
canReparent(child, target, allEntities)
Dry-runs reparent and returns rejected === undefined. Used by drag-over highlighting — zero side effects.
Type guards
const DRAWING_TYPES = new Set(['polyline', 'catmullRom', 'bezier', 'arc', 'rect', 'polygon']);isDrawingEntity—DRAWING_TYPES.has(entityType)isApolloEntityType— its negationisAreaEntity— Apollo: delegates toisApolloAreaEntity(junction/parking/crosswalk/clearArea/area/…). Drawing: onlyrect/polygonisPolygonEditEntity— editPoints form a closed ring? Not identical toisAreaEntity: alaneis an area (junction hitTest needs it) but its editPoints are a centre line (open polyline), so the hot layer must render the rubber-band as a LineString, not a Polygon
Source: entityOps/typeGuards.ts:7-28.
Dependency graph
entityOps.ts (barrel)
├── entityOps/edit.ts
│ ├── core/geometry/apolloCompile (proto-aware fn home)
│ ├── core/elements/derive (derive rules)
│ └── core/geometry/interpolate (LngLat type)
├── entityOps/typeGuards.ts
│ └── core/geometry/apolloCompile
├── entityOps/cascadeDeleteRefs.ts
│ └── types/apollo (types)
└── entityOps/reparent.ts
└── types/apollo (types)2
3
4
5
6
7
8
9
10
11
UI must import from @/lib/entityOps, never core/geometry/apolloCompile.
Test coverage
src/lib/__tests__/entityOps.test.ts exercises:
cascadeDeleteRefsFull— delete lane → other lanes'predecessorIdsare stripped; delete junction →lane.junctionId = null; delete one of two overlap participants → cascadeRemovedreparent— lane→junction, lane→roadSection, cross-road movescanReparentdry runsgetEditPoints/setEditPoint/setAllEditPointsper entity typecreateEntityfor every drawTool
Side effects
- None. All functions are pure: input
entity, allEntities, output a new entity / a change map. - Does not read or write the store.
- Derive runs through
applyDerive(fromcore/elements/derive) and obeys_userOverrides.
Source map (barrel)
| Lines | Content |
|---|---|
| 7–10 | Type re-exports |
| 12–15 | cascadeDeleteRefsFull re-export |
| 17–26 | Edit function re-exports |
| 27–32 | Reparent re-exports |
| 33–38 | typeGuards re-exports |
See also
core/geometry/apolloCompile— actual proto-aware implementations (do not import directly)core/elements/derive— derive-rule engineentitiestypes —MapEntityunionapollotypes — proto typesmapStore— actual mutator callerARCHITECTURE.md"Anti-corruption layer (R2)" — design rationale