Inspector System
Scope: Apollo Map Studio v1, post-2026-04 builds. Closed risks: R1 (
onChangevalidation gate + same-id sync), R2 (entityOps anti-corruption layer).
1. Purpose
The Inspector is the right Dockview panel that turns the currently selected MapEntity into a readable, editable property surface. It must satisfy three hard contracts:
- Type completeness: cover every one of the 17 entity types (lane, junction, parkingSpace, signal, stopSign, road, pncJunction, overlap, area, barrierGate, crosswalk, speedBump, yieldSign, clearArea, rsu, plus six drawing primitives) with a view that is richer than the generic
DrawingFormplaceholder. - Two-way sync without infinite loops: keystrokes flow form → store; undo / canvas drag flows store → form. Neither direction can drive the other into a
watch → updateEntity → reset → watchrecursion. - Live validation feedback:
mode: 'onChange'is non-negotiable. It is the gate that makesformState.isValidreflect the latest keystroke, and the Lane regression test pins it.
2. Data flow overview
3. Entry-point dispatch — InspectorForms.tsx
src/components/layout/panels/InspectorForms.tsx:45-80 is the only type-dispatch site, a plain switch (entity.entityType):
switch (entity.entityType) {
case 'lane': return <LaneForm entity={entity as LaneEntity} />;
case 'junction': return <JunctionForm entity={entity as JunctionEntity} />;
case 'parkingSpace': return <ParkingSpaceForm entity={entity as ...} />;
// ... 14 cases
default: return <DrawingForm entity={entity} />;
}2
3
4
5
6
7
Design intent:
- Zero runtime reflection: TypeScript checks the switch for exhaustiveness via the discriminated union. Adding a new
entityTypewithout updating this switch falls back todefault, not silent crash. - Narrow casts:
entity as LaneEntityis sound insidecase 'lane'. - Drawing fallback: free-form geometry that is not aligned with Apollo proto (polyline, bezier, …) routes to the generic
DrawingForm.
4. Three rendering strategies
| Strategy | Representative entities | Entry |
|---|---|---|
| Schema-driven | LaneEntity | SchemaForm + LaneInspectorSchema |
| Hand-written | Junction/Signal/StopSign/Road | named components in InspectorForms/<entity>.tsx |
| Read-only | Crosswalk/SpeedBump/RSU/... | summary components in InspectorForms/readOnly.tsx |
4.1 Schema-driven — Lane
LaneForm is a thin wrapper that hands LaneInspectorSchema to the generic renderer:
// src/components/layout/panels/InspectorForms/lane.tsx:32
export function LaneForm({ entity }: { entity: LaneEntity }) {
return <SchemaForm schema={LaneInspectorSchema} entity={entity} />;
}2
3
4
Detailed field / read / write / overridesPaths model lives in Inspector Schema.
4.2 Hand-written — Junction / Signal
Why not schema-ify everything? Signal subsignals (an array of bulb records) and signInfo (multi-select flag set) need React-shape inputs that would inflate the FieldDef union to seven variants. SignalForm keeps the JSX explicit in InspectorForms/signal.tsx and splits its subareas into SubsignalsSection and SignInfoSection:
useForm<SignalFormValues>withmode: 'onChange'useEntityFormSync(...)centralizes id-swap reset and same-id drift sync- a single
methods.watch(value => ...)subscription entityRefalways points at the freshest entity- type changes trigger
regenerateSignalGeometryto re-derive boundary + subsignals
Other hand-written forms are split by entity:
| File | Responsibility |
|---|---|
junction.tsx | junction type enum |
parkingSpace.tsx | heading degrees/radians conversion |
stopSign.tsx | stop-sign type enum + stop-line count |
road.tsx | road type, section/lane counts, FK |
area.tsx | area type + optional name |
barrierGate.tsx | barrier-gate type + stop-line count |
signal.tsx | signal type, subsignals, signInfo |
pncJunction.tsx | passage group / passage editor |
overlap.tsx + overlapOverrides.ts | object participants and override pins |
simpleForms.tsx | compatibility re-export only |
4.3 Read-only summary
Crosswalk, SpeedBump, YieldSign, ClearArea, and RSU only expose id + geometry + foreign keys at the proto level. Geometry is edited on the canvas; the FK fields are computed by the topology / overlap reconciler. The Inspector should not own these — it shows counts (vertices / overlapIds) instead. Implementation is centralized in InspectorForms/readOnly.tsx through the shared ReadOnlyAttributes shell.
4.4 Hand-written form sync hook
Hand-written forms no longer duplicate entityRef, reset([entity.id]), and same-id setValue boilerplate. They use InspectorForms/formSync.ts:
const entityRef = useEntityFormSync(entity, methods, formValuesFromSignal);The hook handles id-swap reset and silent same-id drift sync. Each entity file keeps only its formValuesFrom<Entity>() projection and the methods.watch(...) store-write rule.
5. The R1 closure for two-way sync
R1 is documented at SchemaForm.tsx:18-23:
Behavior contract: this component MUST preserve the validation gate fix from commit 6a83d9d —
mode: 'onChange'is the gate that makesformState.isValidreflect the live keystroke status, which the Lane regression test pins.
Three layers of defense:
mode: 'onChange'— never switch to'onSubmit'. UnderonSubmitthe legacyformState.isValidgate stays false during typing and silently blocks persistence.- id-swap reset —
useEffect(..., [entity.id])only resets when the entity reference changes id, so a same-id store update does not erase what the user is typing. - same-id cherry-pick —
useEffect(..., [entity])runsdiffFormAgainstEntityto compute the per-field gap between the form value and the entity reading; only those fields aresetValue-ed withshouldDirty: false.
The third effect is what keeps the panel "in sync with the canvas" during undo, redo, and drag-handle edits.
6. The shouldPersistForm gate against death loops
// src/components/layout/panels/SchemaForm.tsx:106-114
useEffect(() => {
const subscription = methods.watch((value) => {
const liveEntity = entityRef.current;
if (!shouldPersistForm(schema, value as Partial<TFormValues>, liveEntity)) return;
const next = applyFormValuesToEntity(schema, liveEntity, value as Partial<TFormValues>);
updateEntity(liveEntity.id, next);
});
return () => subscription.unsubscribe();
}, [methods, updateEntity, schema]);2
3
4
5
6
7
8
9
10
Without the gate the cycle is:
keystroke → setValue → watch → updateEntity → store new ref
→ cherry-pick effect → setValue → watch → updateEntity → ...2
shouldPersistForm short-circuits when the form value already matches the entity projection, breaking the loop.
entityRef.current (instead of the closed-over entity) is the second guard — the closure must always read the freshest snapshot or stale data leaks back into the store.
7. User override tagging (_userOverrides)
Some fields have derive rules (e.g. length is normally integrated from centralCurve). After a user manually types length, future geometry edits must not clobber that value.
applyFormValuesToEntity enforces this:
// src/types/inspectorSchema.ts:507-540
const prevValue = (field.read as (e: TEntity) => unknown)(next);
next = writer(next, v);
const newValue = (field.read as (e: TEntity) => unknown)(next);
if (prevValue !== newValue) {
const paths = field.overridesPaths ?? [String(field.name)];
for (const path of paths) {
next = markUserOverride(next, path);
}
}2
3
4
5
6
7
8
9
10
Only actual changes are tagged; redundant writes (re-seeding the same value) must not promote a derived value into a manual override.
8. Public API summary
| Symbol | File | Use |
|---|---|---|
EntityForm | InspectorForms.tsx | Top-level dispatcher |
LaneForm | InspectorForms/lane.tsx | Schema wrapper |
SchemaForm | panels/SchemaForm.tsx | Generic schema renderer |
JunctionForm / SignalForm | InspectorForms/junction.tsx etc. | Hand-written variants |
OverlapForm | InspectorForms/overlap.tsx | Object pair editor |
PNCJunctionForm | InspectorForms/pncJunction.tsx | Passage / control |
DrawingForm | InspectorForms/DrawingForm.tsx | Generic geometry summary |
useEntityFormSync | InspectorForms/formSync.ts | Hand-written form sync boilerplate |
zodResolverZ4 | InspectorForms/resolver.ts | zod v4 + react-hook-form bridge |
9. Type contract: discriminated union + zod
MapEntity is a union of 17 entityType literals; the InspectorForms.tsx switch is exhaustive under strict TypeScript. @/lib/schemas.ts exports a zod schema per entity, e.g. laneSchema: ZodType<LaneFormValues, LaneFormValues>. zodResolverZ4 adapts it to react-hook-form's resolver shape.
const methods = useForm<LaneFormValues>({
resolver: zodResolverZ4<LaneFormValues>(laneSchema),
mode: 'onChange',
defaultValues: laneFormValuesFromEntity(entity),
});2
3
4
5
10. Performance and accessibility
- The Inspector renders one entity form at a time; multi-select yields an empty pane (extension point for future "shared fields").
methods.watchsubscriptions are mounted once per form lifetime (deps[methods, updateEntity, schema]).groupBySectionsis wrapped inuseMemo([schema])so the section layout is computed once per schema.- All inputs use semantic
<Input />/<Select />components with matchingnameandaria-labelso the panel is keyboard reachable and screen-reader friendly.
11. Pitfalls
- Do not call
methods.setValueinside the watch callback — it creates an internal signal loop. Only write to the store there. - Do not regress
modeto'onSubmit'— the Lane regression test will fail (seesrc/components/layout/panels/__tests__/InspectorForms.test.ts). - Never drop
entityRef.current. The closed-overentitybecomes stale across watch ticks; always read from the ref. - When schema-ifying a new entity, declare its sections in
sectionOrder. Sections that are referenced but absent will be appended in declaration order, breaking the visual layout. applyFormValuesToEntityis order-sensitive: writes apply left-to-right and each step sees the result of the previous. Place "depended-on" fields before fields that derive from them.- Do not put new form implementations back into
simpleForms.tsx. Add new hand-written forms underInspectorForms/<entity>.tsx, shared sync code informSync.ts, and read-only summaries inreadOnly.tsx.
12. Test coverage
| Test | Asserts |
|---|---|
src/components/layout/panels/__tests__/InspectorForms.test.ts | Lane R1: shouldPersistLaneForm / diffLaneFormAgainstEntity |
src/components/layout/panels/__tests__/overlapInspector.test.ts | Overlap selector + object pair editor |
src/hooks/__tests__/undoCancel.test.ts | FSM CANCEL ordered before temporal.undo() (R1 protector) |
src/lib/__tests__/entityOps.test.ts | entityOps anti-corruption layer (R2) |
13. Source map
src/
├── components/layout/panels/
│ ├── InspectorForms.tsx ← entry switch
│ ├── SchemaForm.tsx ← generic schema renderer
│ ├── LaneRefList.tsx ← topology chip
│ └── InspectorForms/
│ ├── DrawingForm.tsx
│ ├── area.tsx
│ ├── barrierGate.tsx
│ ├── formSync.ts ← hand-written form sync hook
│ ├── junction.tsx
│ ├── lane.tsx ← schema-driven Lane
│ ├── overlap.tsx
│ ├── overlapOverrides.ts
│ ├── parkingSpace.tsx
│ ├── pncJunction.tsx
│ ├── readOnly.tsx ← Crosswalk / SpeedBump / RSU ...
│ ├── resolver.ts
│ ├── road.tsx
│ ├── signal.tsx
│ ├── simpleForms.tsx ← compatibility re-export only
│ └── stopSign.tsx
├── types/
│ ├── inspectorSchema.ts ← FieldDef / EntitySchema / helpers
│ └── entities.ts ← MapEntity discriminated union
├── lib/
│ ├── schemas.ts ← zod schemas + options
│ └── enumLabels.ts ← display label dictionary
└── core/elements/derive.ts ← markUserOverride2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
14. Cooperation with the FSM
XState 5's editorMachine lives outside the Inspector and manages "are we currently drawing / editing a vertex". The Inspector does not subscribe to the FSM, but two implicit contracts exist:
- CANCEL first: undo / redo dispatch must call
actorRef.send({ type: 'CANCEL' })before invokingtemporal.undo(). Otherwise mapStore rolls back while the FSM keeps its staledrawPoints, and the next schema → entity projection diverges. Enforced byuseActionDispatcher; pinned byundoCancel.test.ts. - Canvas selection drives entity ref: changes to
uiStore.selectedIdsswap the Inspector entity, and both internaluseEffects react automatically.
15. Internationalisation (current state)
Per the user memory, i18next has not been adopted. Every label is hardcoded English: Type / Length / Predecessors, etc. Enum display passes through getEnumLabel (src/lib/enumLabels.ts), which is the only future i18n hook — translating that function covers enum option labels, but ordinary field labels still have no translation pathway.
16. See also
- Inspector Schema — field shapes, validators, lane refs, overlap pinning
- Anti-corruption Layer — entityOps insulation between UI and proto
- State Management — Zustand + zundo undo stack
- FSM Design — how the editor state machine interacts with forms
- Testing Strategy — Inspector regression tests and fixtures