Skip to main content

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

  1. Context & Problem Statement
  2. Decision Summary
  3. Current System: Tag-Drop via @dnd-kit
  4. Target System: Port-and-Wire via SVG
  5. Port Key Schema
  6. Connection Lifecycle
  7. Visual Design
  8. Drag State Machine
  9. SVG Connection Rendering
  10. Integration with mappingStore
  11. Accessibility
  12. Performance
  13. Migration Path
  14. Rejected Alternatives
  15. Open Questions

1. Context & Problem Statement

The Schema Designer has two interaction paths for creating mappings between columns:

  1. 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.
  2. 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

  • SourceColumn renders each field as a DraggableFieldTag with useDraggable.
  • OntologyColumn renders each field row as DroppableDraggableFieldRow with both useDroppable (receives source fields) and useDraggable (can be dragged to matrix).
  • MatrixColumn renders each factor as DroppableFactorSlot with useDroppable.
  • A single DndContext wraps all three columns with closestCenter collision detection.
  • DragOverlay shows a floating tag preview during drag.
  • handleDragEnd reads the active/over data and calls store.addSourceMapping() or store.setMatrixMapping().

Limitations

  1. 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.
  2. 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).
  3. No inline delete. Mappings can only be removed by re-saving or clearing. There's no "click the wire to disconnect" gesture.
  4. 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.
  5. 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:

PrefixSideColumn
src.srcSource (right edge)
ont.L.ontLOntology left edge (input from sources)
ont.R.ontROntology right edge (output to matrix)
mat.matMatrix (left edge)

Connection rules

FromToMeaning
srcontLSource field feeds ontology field
ontLsrcSame (reverse direction initiation)
ontRmatOntology field feeds matrix factor
matontRSame (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

  1. User mousedowns on a port dot.
  2. handleDragStart captures the origin port key, its side, cursor position, and port color.
  3. Global mousemove listener updates cursor position and checks document.elementFromPoint for hover targets.
  4. Valid target ports (per isValidDropSide) glow green. Invalid targets dim.
  5. On mouseup over a valid target: createConnection(fromKey, toKey) parses both keys, determines connection type (source or matrix), and calls the appropriate store action.
  6. The connection is immediately rendered as a persistent SVG curve.

Read

All existing connections are derived from the Zustand store state:

  • Source connections: Iterate fieldMappings entries. For each (entityType.fieldName, { sources }), generate one ConnectionDef per source, with fromKey = src.{sourceId}.{entity}.{field} and toKey = ont.L.{entity}.{field}.
  • Matrix connections: Iterate matrixMappings. For each mapping with a non-null ontologyFieldPath, generate a ConnectionDef with fromKey = ont.R.{entity}.{field} and toKey = mat.{dimension}.{factor}.

No separate connection state is needed. The SVG layer is a pure projection of the store.

Delete

  1. User hovers over a connection line (invisible 12px-wide hit area on the SVG path).
  2. The wire turns red and a delete icon appears at the midpoint.
  3. Click calls onDeleteConnection(connId).
  4. Handler parses the connection ID, calls store.removeSourceMapping() or store.removeMatrixMapping().
  5. The wire disappears on re-render (since it's derived from store state).

7. Visual Design

Port dot states

StateBackgroundBorderScaleShadow
DisconnectedtransparentDARK_GRAY51.0none
Connectedentity colorentity color1.0none
Required + disconnectedtransparentRED31.0none
Drag originentity colorentity color1.40 0 6px {color}88
Valid drop target (during drag)GREEN3 at 25%GREEN41.30 0 4px {GREEN3}44
Hover target (cursor over)GREEN3GREEN31.50 0 8px {GREEN3}aa
Invalid target (during drag)dimmedDARK_GRAY51.0none

Connection wire styles

StateStrokeWidthOpacityDash
Normalentity color1px + 1.5px shadow0.8 / 0.3solid
With transformentity color1px + 1.5px shadow0.8 / 0.34,3 dashed
HoveredRED32px + 2.5px shadow1.0 / 0.5solid
Drag previeworigin color1px + 2px1.0 / 0.66,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:

  1. On connection add/remove (store change triggers re-render).
  2. On scroll within any column (columns are independently scrollable).
  3. On container resize.
  4. 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:

  1. 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.
  2. Screen reader announcements. Each port dot has aria-label="Connect {sourceId} {fieldName} to ontology field". Connection creation/deletion announces via aria-live region.
  3. 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

  1. Create components/studio/PortDot.tsx from the mockup's PortDot.
  2. Create components/studio/ConnectionLines.tsx from the mockup's ConnectionLines.
  3. Create utils/portKeys.ts with getPortSide, isValidDropSide, parseSrcPort, parseOntPort, parseMatPort.
  4. Create hooks/useWireDrag.ts encapsulating the drag state machine (mousedown/move/up listeners, DragState, cursor tracking).

Phase 2: Add ports to column components

  1. SourceColumn.tsx: Replace useDraggable / DraggableFieldTag with PortDot on the right edge of each field row. Remove @dnd-kit imports.
  2. OntologyColumn.tsx: Replace useDroppable / useDraggable / DroppableDraggableFieldRow with two PortDot elements (left + right) per field row. Remove @dnd-kit imports.
  3. MatrixColumn.tsx: Replace useDroppable / DroppableFactorSlot with PortDot on the left edge of each factor. Remove @dnd-kit imports.

Phase 3: Wire up the SVG layer

  1. In MappingDesigner.tsx: Remove DndContext, DragOverlay, handleDragStart, handleDragEnd, and all @dnd-kit imports.
  2. Add a ref on the three-column container div.
  3. Add useWireDrag hook. Pass dragState and handlers down to all columns.
  4. Render <ConnectionLines> inside the container div, positioned absolutely.
  5. Derive ConnectionDef[] from store.fieldMappings and store.matrixMappings using deriveConnections().
  6. Wire createConnection and onDeleteConnection to store actions.

Phase 4: Cleanup

  1. Remove @dnd-kit/core from Schema Designer imports. It remains in the project for PhaseListPanel and DimensionEditor.
  2. Delete the mockup file (MappingDesignerMockup.tsx) once the production system is complete.
  3. 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

  1. Connection animation on creation. Yes. Connections animate in with a ~200ms grow-in (SVG stroke-dashoffset animation from full length to 0). Gives satisfying visual feedback that the wire was created.

  2. 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.

  3. Touch support. No. The Schema Designer targets desktop compliance officers. No touch event support needed.

  4. Connection routing. Fixed three-column order is assumed. Bezier control points at 45% horizontal distance. No reorderable or collapsible columns.

  5. 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).