ADR-016: Port-and-Wire Interaction Model for Schema Designer
Status: Proposed Date: 2026-03-31 Author: Atlas Architecture Depends on: ADR-010 (Ontology Lifecycle), ADR-015 (URL-Driven Navigation State) Impacts: Schema Designer (MappingDesigner), SourceColumn, OntologyColumn, MatrixColumn, mappingStore
Table of Contents
- Context & Problem Statement
- Decision Summary
- Current System: Tag-Drop via @dnd-kit
- Target System: Port-and-Wire via SVG
- Port Key Schema
- Connection Lifecycle
- Visual Design
- Drag State Machine
- SVG Connection Rendering
- Integration with mappingStore
- Accessibility
- Performance
- Migration Path
- Rejected Alternatives
- Resolved Questions
1. Context & Problem Statement
The Schema Designer has two interaction paths for creating mappings between columns:
- Source field to Ontology field (left to center): Connect a data source's field to an ontology field to declare that this source provides data for that field.
- Ontology field to Risk Matrix factor (center to right): Connect an ontology field to a risk matrix dimension factor to declare what data feeds the scoring model.
The current production system uses @dnd-kit with a tag-drop model: the user drags an entire field tag from one column and drops it on a target in another column. This creates a mapping.
The data model (mappingStore) already supports many-to-many: one ontology field can receive data from multiple sources (sources: string[]), and any source field can feed multiple ontology fields. But the tag-drop interaction doesn't communicate this well. Dragging a tag feels like moving it, not connecting it. There's no persistent visual showing which fields are connected, no way to see the full wiring at a glance, and deleting a mapping requires knowing it exists first.
A reference mockup (MappingDesignerMockup.tsx, 1918 lines) implements a port-and-wire model using SVG Bezier curves, data-port-key attributes, and a custom drag state machine. The mockup proves the interaction works and looks right in the Atlas dark theme. This ADR formalizes the approach and defines the migration from @dnd-kit to port-and-wire.
2. Decision Summary
Replace the @dnd-kit DndContext/useDraggable/useDroppable system in the Schema Designer with a custom port-and-wire interaction model based on the reference mockup. Each field gets small port dots. The user mousedowns on a port and drags a wire (SVG Bezier curve) to a valid target port, creating a persistent visual connection. Connections are deletable by hovering a wire and clicking.
@dnd-kit remains in the codebase for other uses (workflow phase reordering in PhaseListPanel.tsx, factor reordering in DimensionEditor.tsx) but is removed from the Schema Designer.
3. Current System: Tag-Drop via @dnd-kit
How it works
SourceColumnrenders each field as aDraggableFieldTagwithuseDraggable.OntologyColumnrenders each field row asDroppableDraggableFieldRowwith bothuseDroppable(receives source fields) anduseDraggable(can be dragged to matrix).MatrixColumnrenders each factor asDroppableFactorSlotwithuseDroppable.- A single
DndContextwraps all three columns withclosestCentercollision detection. DragOverlayshows a floating tag preview during drag.handleDragEndreads the active/over data and callsstore.addSourceMapping()orstore.setMatrixMapping().
Limitations
- No persistent visual. Once a mapping exists, there's no line or wire showing the connection. The only indication is small source badges on the ontology field row. The user can't see the full wiring topology.
- Feels like move, not connect. Dragging a tag implies relocation, not linking. Users expect the source tag to disappear from the source column after drag (it doesn't).
- No inline delete. Mappings can only be removed by re-saving or clearing. There's no "click the wire to disconnect" gesture.
- One direction only. Dragging always starts from the source. You can't grab an ontology field's port and wire it backward to a source.
- No hover preview. During drag, there's no preview of what the connection would look like. The drop target just gets a subtle background tint.
4. Target System: Port-and-Wire via SVG
Core concepts
Each field in all three columns gets a port dot (8px circle) on its connection edge:
- Source fields: port on the right edge
- Ontology fields: port on the left edge (receives from sources) AND port on the right edge (connects to matrix)
- Matrix factors: port on the left edge
The user clicks a port and drags. An SVG Bezier curve follows the cursor in real-time. Valid drop targets glow green. Dropping on a valid port creates a persistent connection rendered as an SVG curve between the two ports. Hovering a connection reveals a delete button.
Key properties
- Bidirectional initiation. Wire can start from either end (source right-port or ontology left-port). This means both "I want to connect this source to something" and "I want to find a source for this ontology field" workflows are supported.
- Many-to-many visual. Multiple wires can fan out from a single port, making the topology visible. One source field wired to three ontology fields is three visible curves.
- Inline delete. Hover a wire to see it highlight red with a delete button at the midpoint. Click to disconnect.
- No DOM movement. Nothing moves during drag. The fields stay in place. Only the SVG cursor-following line animates. This communicates "connecting" rather than "moving."
5. Port Key Schema
Every port is identified by a hierarchical string key stored as a data-port-key attribute on its DOM element. The key encodes column, entity, and field:
Source fields: src.{sourceId}.{entityType}.{fieldName}
Ontology left: ont.L.{entityType}.{fieldName}
Ontology right: ont.R.{entityType}.{fieldName}
Matrix factors: mat.{dimensionKey}.{factorName}
Examples:
src.kvk.LegalEntity.legal_name
src.northdata.LegalEntity.employee_count
ont.L.LegalEntity.legal_name
ont.R.LegalEntity.legal_name
mat.Customer Risk.entity_type
Side extraction
The first token determines the port side:
| Prefix | Side | Column |
|---|---|---|
src. | src | Source (right edge) |
ont.L. | ontL | Ontology left edge (input from sources) |
ont.R. | ontR | Ontology right edge (output to matrix) |
mat. | mat | Matrix (left edge) |
Connection rules
| From | To | Meaning |
|---|---|---|
src | ontL | Source field feeds ontology field |
ontL | src | Same (reverse direction initiation) |
ontR | mat | Ontology field feeds matrix factor |
mat | ontR | Same (reverse direction initiation) |
All other combinations are invalid. src to mat is invalid (must go through ontology). src to ontR is invalid (wrong port side). This is enforced by isValidDropSide().
6. Connection Lifecycle
Create
- User mousedowns on a port dot.
handleDragStartcaptures the origin port key, its side, cursor position, and port color.- Global
mousemovelistener updates cursor position and checksdocument.elementFromPointfor hover targets. - Valid target ports (per
isValidDropSide) glow green. Invalid targets dim. - On
mouseupover a valid target:createConnection(fromKey, toKey)parses both keys, determines connection type (source or matrix), and calls the appropriate store action. - The connection is immediately rendered as a persistent SVG curve.
Read
All existing connections are derived from the Zustand store state:
- Source connections: Iterate
fieldMappingsentries. For each(entityType.fieldName, { sources }), generate oneConnectionDefper source, withfromKey = src.{sourceId}.{entity}.{field}andtoKey = ont.L.{entity}.{field}. - Matrix connections: Iterate
matrixMappings. For each mapping with a non-nullontologyFieldPath, generate aConnectionDefwithfromKey = ont.R.{entity}.{field}andtoKey = mat.{dimension}.{factor}.
No separate connection state is needed. The SVG layer is a pure projection of the store.
Delete
- User hovers over a connection line (invisible 12px-wide hit area on the SVG path).
- The wire turns red and a delete icon appears at the midpoint.
- Click calls
onDeleteConnection(connId). - Handler parses the connection ID, calls
store.removeSourceMapping()orstore.removeMatrixMapping(). - The wire disappears on re-render (since it's derived from store state).
7. Visual Design
Port dot states
| State | Background | Border | Scale | Shadow |
|---|---|---|---|---|
| Disconnected | transparent | DARK_GRAY5 | 1.0 | none |
| Connected | entity color | entity color | 1.0 | none |
| Required + disconnected | transparent | RED3 | 1.0 | none |
| Drag origin | entity color | entity color | 1.4 | 0 0 6px {color}88 |
| Valid drop target (during drag) | GREEN3 at 25% | GREEN4 | 1.3 | 0 0 4px {GREEN3}44 |
| Hover target (cursor over) | GREEN3 | GREEN3 | 1.5 | 0 0 8px {GREEN3}aa |
| Invalid target (during drag) | dimmed | DARK_GRAY5 | 1.0 | none |
Connection wire styles
| State | Stroke | Width | Opacity | Dash |
|---|---|---|---|---|
| Normal | entity color | 1px + 1.5px shadow | 0.8 / 0.3 | solid |
| With transform | entity color | 1px + 1.5px shadow | 0.8 / 0.3 | 4,3 dashed |
| Hovered | RED3 | 2px + 2.5px shadow | 1.0 / 0.5 | solid |
| Drag preview | origin color | 1px + 2px | 1.0 / 0.6 | 6,4 dashed |
Connection curve geometry
Horizontal Bezier with control points at 45% of the horizontal distance:
M x1 y1 C (x1 + dx) y1, (x2 - dx) y2, x2 y2
where dx = |x2 - x1| * 0.45
This creates smooth S-curves that avoid crossing column boundaries awkwardly.
Transform indicator
Connections with a data transformation show a dashed line with a small f badge (16x16 rounded rect with italic "f" text) at the midpoint of the curve.
Delete affordance
On wire hover: red circle (r=9) at curve midpoint with white "x" character. Pointer events on the fat invisible hit path (12px stroke), not on the visible thin path.
8. Drag State Machine
IDLE
│
├── mousedown on port dot
│ └── DRAGGING { fromKey, fromSide, cursorX, cursorY, hoverTarget: null, color }
│ │
│ ├── mousemove → update cursorX/cursorY, check elementFromPoint
│ │ ├── over valid port → hoverTarget = portKey
│ │ └── over nothing/invalid → hoverTarget = null
│ │
│ ├── mouseup over valid target → createConnection(), go to IDLE
│ ├── mouseup over nothing → cancel, go to IDLE
│ └── Escape key → cancel, go to IDLE
│
└── (no mousedown) → IDLE
State is stored as DragState | null in the top-level component. When non-null, all port dots receive the drag state as a prop to compute their visual state. The SVG overlay reads drag state to render the cursor-following preview line.
Global mousemove and mouseup listeners are attached/detached via useEffect keyed on dragState !== null.
9. SVG Connection Rendering
Architecture
A single <svg> element is positioned absolutely over the three-column container with pointer-events: none (except on hit areas). It renders all connection curves and the drag preview line.
Port position resolution
Port positions are resolved by querying the DOM:
const portEl = containerRef.current.querySelector(`[data-port-key="${CSS.escape(portKey)}"]`);
const portRect = portEl.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const x = portRect.left + portRect.width / 2 - containerRect.left;
const y = portRect.top + portRect.height / 2 - containerRect.top;
Update strategy
Connection paths are recalculated:
- On connection add/remove (store change triggers re-render).
- On scroll within any column (columns are independently scrollable).
- On container resize.
- On a 200ms interval as a fallback for edge cases (accordion expand/collapse, async data load shifting layout).
The interval is a safety net; most updates are event-driven. The 200ms poll can be removed once all layout-shifting events are covered by explicit recalc triggers.
10. Integration with mappingStore
The port-and-wire system does not introduce new store state. Connections are a view projection of the existing fieldMappings and matrixMappings in the Zustand store.
Connection creation
function createConnection(fromKey: string, toKey: string, fromSide: PortSide, toSide: PortSide) {
// Normalize direction (always src→ontL or ontR→mat)
const [srcKey, tgtKey] = fromSide === 'src' || fromSide === 'ontR'
? [fromKey, toKey]
: [toKey, fromKey];
if (getPortSide(srcKey) === 'src') {
// Source → Ontology
const src = parseSrcPort(srcKey);
const ont = parseOntPort(tgtKey);
if (src && ont) {
store.addSourceMapping(ont.entity, ont.field, src.sourceId);
}
} else {
// Ontology → Matrix
const ont = parseOntPort(srcKey);
const mat = parseMatPort(tgtKey);
if (ont && mat) {
store.setMatrixMapping(mat.dimension, mat.factor, `${ont.entity}.${ont.field}`);
}
}
}
Connection derivation (for rendering)
function deriveConnections(fieldMappings: Map<string, FieldMapping>, matrixMappings: MatrixFactorMapping[]): ConnectionDef[] {
const conns: ConnectionDef[] = [];
// Source → Ontology connections
for (const [key, mapping] of fieldMappings) {
for (const sourceId of mapping.sources) {
conns.push({
id: `src.${sourceId}.${mapping.entityType}.${mapping.fieldName}::ont.L.${mapping.entityType}.${mapping.fieldName}`,
fromKey: `src.${sourceId}.${mapping.entityType}.${mapping.fieldName}`,
toKey: `ont.L.${mapping.entityType}.${mapping.fieldName}`,
color: getEntityColor(mapping.entityType),
hasTransform: false, // TODO: derive from field config
});
}
}
// Ontology → Matrix connections
for (const mm of matrixMappings) {
if (!mm.ontologyFieldPath) continue;
const [entity, field] = mm.ontologyFieldPath.split('.');
conns.push({
id: `ont.R.${entity}.${field}::mat.${mm.matrixSchemaKey}.${mm.factorName}`,
fromKey: `ont.R.${entity}.${field}`,
toKey: `mat.${mm.matrixSchemaKey}.${mm.factorName}`,
color: getEntityColor(entity),
hasTransform: false,
});
}
return conns;
}
Dirty tracking
The store's existing dirty flag covers all mapping changes. No additional dirty tracking is needed for the wire system.
11. Accessibility
Port-and-wire interactions are inherently mouse-driven. For keyboard accessibility:
- Keyboard connection mode. When a port dot is focused (tabindex=0), pressing Enter enters "wiring mode." Arrow keys cycle through valid target ports (highlighted sequentially). Enter confirms, Escape cancels.
- Screen reader announcements. Each port dot has
aria-label="Connect {sourceId} {fieldName} to ontology field". Connection creation/deletion announces viaaria-liveregion. - Connection list. A hidden
<ul>enumerates all active connections as text for screen readers, e.g., "kvk.LegalEntity.legal_name connected to LegalEntity.legal_name".
This is a progressive enhancement. The initial implementation can ship mouse-only and add keyboard support as a follow-up.
12. Performance
Port count estimate
A typical schema with 7 sources, 5 entity groups averaging 8 fields each, and 5 risk matrix dimensions averaging 4 factors:
- Source ports: 7 sources x 5 groups x 8 fields = 280 ports (but only active sources render, typically 3-4 = ~120)
- Ontology ports: 5 groups x 8 fields x 2 sides = 80 ports
- Matrix ports: 5 dimensions x 4 factors = 20 ports
- Total: ~220 port dots, ~50-100 connection curves
This is well within SVG rendering limits. No virtualization needed.
Scroll synchronization
Each column scrolls independently. On scroll, connection curve endpoints shift. The ConnectionLines component recalculates paths. Since getBoundingClientRect() returns viewport-relative coordinates that already account for scroll position, no manual scroll offset tracking is needed. The 200ms interval catches any missed scroll events.
Resize handling
ResizeObserver on the container triggers path recalculation.
13. Migration Path
Phase 1: Extract shared primitives
- Create
components/studio/PortDot.tsxfrom the mockup'sPortDot. - Create
components/studio/ConnectionLines.tsxfrom the mockup'sConnectionLines. - Create
utils/portKeys.tswithgetPortSide,isValidDropSide,parseSrcPort,parseOntPort,parseMatPort. - Create
hooks/useWireDrag.tsencapsulating the drag state machine (mousedown/move/up listeners, DragState, cursor tracking).
Phase 2: Add ports to column components
SourceColumn.tsx: ReplaceuseDraggable/DraggableFieldTagwithPortDoton the right edge of each field row. Remove @dnd-kit imports.OntologyColumn.tsx: ReplaceuseDroppable/useDraggable/DroppableDraggableFieldRowwith twoPortDotelements (left + right) per field row. Remove @dnd-kit imports.MatrixColumn.tsx: ReplaceuseDroppable/DroppableFactorSlotwithPortDoton the left edge of each factor. Remove @dnd-kit imports.
Phase 3: Wire up the SVG layer
- In
MappingDesigner.tsx: RemoveDndContext,DragOverlay,handleDragStart,handleDragEnd, and all @dnd-kit imports. - Add a
refon the three-column container div. - Add
useWireDraghook. PassdragStateand handlers down to all columns. - Render
<ConnectionLines>inside the container div, positioned absolutely. - Derive
ConnectionDef[]fromstore.fieldMappingsandstore.matrixMappingsusingderiveConnections(). - Wire
createConnectionandonDeleteConnectionto store actions.
Phase 4: Cleanup
- Remove
@dnd-kit/corefrom Schema Designer imports. It remains in the project forPhaseListPanelandDimensionEditor. - Delete the mockup file (
MappingDesignerMockup.tsx) once the production system is complete. - Update ADR-010 section 14 (Compliance Studio Organization) to reference the wire-based interaction model.
Estimated effort
Phase 1: half day (mostly extracting and typing existing mockup code). Phase 2: half day per column (3 columns = 1.5 days). Phase 3: 1 day (integration, testing scroll sync, connection derivation). Phase 4: 2 hours (cleanup). Total: ~3 days.
14. Rejected Alternatives
Keep @dnd-kit, add connection lines as a read-only overlay
Add SVG connection lines showing existing mappings but keep tag-drop for creating them. This gives visual topology without changing the interaction model. Rejected because it creates a split experience: you see wires but create mappings by dragging tags, which is confusing. It also doesn't solve the delete problem or bidirectional initiation.
Use a library (react-flow, xyflow)
React Flow / XYFlow is a full node-graph editor. It handles connection lines, ports, and drag natively. Rejected because it imposes its own layout model (free-form node canvas), which conflicts with the fixed three-column layout. We'd fight the library to constrain nodes to columns. The port-and-wire subset we need is simple enough to own (the mockup already proves this at ~300 lines for the SVG layer).
Canvas-based rendering instead of SVG
Render connection lines on a <canvas> element instead of SVG. Better for thousands of elements. Rejected because our port count (~220) is well within SVG limits, and SVG gives us free DOM event handling (hover, click) on individual paths without hit-testing math.
Keep @dnd-kit with custom collision detection
Write a custom collision detector that only triggers when dragging over a small port region rather than the full field row. This would approximate port-to-port interaction within the @dnd-kit model. Rejected because it still moves the entire tag (wrong affordance), doesn't draw connection lines, and can't support bidirectional initiation from the target side.
15. Resolved Questions
-
Connection animation on creation. Yes. Connections animate in with a ~200ms grow-in (SVG
stroke-dashoffsetanimation from full length to 0). Gives satisfying visual feedback that the wire was created. -
Port count badges. Yes. When a port has multiple outgoing wires, show a small count badge (e.g., "3") next to the port dot. Helps with dense schemas where multiple wires fan out from a single source field.
-
Touch support. No. The Schema Designer targets desktop compliance officers. No touch event support needed.
-
Connection routing. Fixed three-column order is assumed. Bezier control points at 45% horizontal distance. No reorderable or collapsible columns.
-
Undo/redo. No command stack. Connection delete (hover wire, click red X) is sufficient. No Ctrl+Z support. If a user accidentally deletes a wire, they re-draw it (same gesture as creating one, takes ~1 second).