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
- Open 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 |