Field Mapping in Scrolling Lists
Two long lists side by side — the columns of an uploaded file and the fields of a database — with lines between the rows the user has matched. The lists scroll, so a matched row can be out of view; its line then stays attached to the edge of the list instead of dangling over the page.
What it uses:
- Ports on a row — each row is a port of its panel.
- Scrolling containers —
clipToScrollParentspins or hides the end of a line whose row has scrolled away. - Selection & accessibility — selecting a line and removing it with Delete.
Plain JavaScript
html
<div id="diagram">
<div class="panel" id="source"><h3>CSV columns</h3></div>
<div class="panel" id="target"><h3>Database fields</h3></div>
</div>css
body {
margin: 0;
font:
14px system-ui,
sans-serif;
background: #f8fafc;
}
#diagram {
position: relative;
display: flex;
justify-content: space-between;
margin: 16px;
padding: 0 40px;
}
.panel {
width: 240px;
height: 260px;
overflow-y: auto;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 8px;
}
.panel h3 {
position: sticky;
top: 0;
margin: 0;
padding: 8px 12px;
background: #e2e8f0;
font-size: 13px;
}
.row {
padding: 8px 12px;
border-top: 1px solid #f1f5f9;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.picked {
background: #e0e7ff;
}
.row small {
color: #64748b;
}ts
import { createVisualLinker, type ConnectionDescriptor } from '@macrulez/visual-linker-core'
const sourceFields = [
'first_name',
'last_name',
'email_address',
'phone',
'company',
'job_title',
'city',
'country',
'zip',
'notes',
]
const targetFields = [
'givenName',
'familyName',
'email',
'mobile',
'organization',
'position',
'town',
'countryCode',
'postalCode',
'comment',
]
let mappings: ConnectionDescriptor[] = [
{
id: 'first_name>givenName',
from: { blockId: 'source', portId: 'first_name' },
to: { blockId: 'target', portId: 'givenName' },
},
{
id: 'email_address>email',
from: { blockId: 'source', portId: 'email_address' },
to: { blockId: 'target', portId: 'email' },
},
{
id: 'notes>comment',
from: { blockId: 'source', portId: 'notes' },
to: { blockId: 'target', portId: 'comment' },
},
]
const fill = (panel: HTMLElement, fields: string[]) => {
panel.insertAdjacentHTML(
'beforeend',
fields.map((f) => `<div class="row" data-field="${f}">${f}</div>`).join(''),
)
}
const diagram = document.querySelector<HTMLElement>('#diagram')!
const source = document.querySelector<HTMLElement>('#source')!
const target = document.querySelector<HTMLElement>('#target')!
fill(source, sourceFields)
fill(target, targetFields)
const linker = createVisualLinker(diagram, {
lines: { curve: 'bezier', width: 2, hover: { width: 3 } },
markers: { start: { shape: 'circle' }, end: { shape: 'circle' } },
interaction: { hover: true, selectable: true, clipToScrollParents: 'pin' },
})
const ports = (fields: string[], side: 'left' | 'right') =>
fields.map((f) => ({ id: f, target: `[data-field="${f}"]`, side }))
linker.setBlocks([
{ id: 'source', el: source, ports: ports(sourceFields, 'right') },
{ id: 'target', el: target, ports: ports(targetFields, 'left') },
])
linker.setConnections(mappings)
linker.on('connection:delete-request', ({ connections }) => {
const ids = new Set(connections.map((c) => c.id))
mappings = mappings.filter((c) => !ids.has(c.id))
linker.setConnections(mappings)
})
let picked: HTMLElement | null = null
diagram.addEventListener('click', (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>('.row')
if (!row) return
if (row.parentElement === source) {
picked?.classList.remove('picked')
picked = picked === row ? null : row
picked?.classList.add('picked')
return
}
if (!picked) return
const id = `${picked.dataset.field}>${row.dataset.field}`
if (!mappings.some((m) => m.id === id)) {
mappings = [
...mappings,
{
id,
from: { blockId: 'source', portId: picked.dataset.field! },
to: { blockId: 'target', portId: row.dataset.field! },
},
]
linker.setConnections(mappings)
}
picked.classList.remove('picked')
picked = null
})Vue
The styles are the same as above, in a <style scoped> block.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VisualLinker, vVlBlock, vVlPort } from '@macrulez/visual-linker-vue'
import type { ConnectionDescriptor, VisualLinkerConfig } from '@macrulez/visual-linker-vue'
const sourceFields = [
'first_name',
'last_name',
'email_address',
'phone',
'company',
'job_title',
'city',
'country',
'zip',
'notes',
]
const targetFields = [
'givenName',
'familyName',
'email',
'mobile',
'organization',
'position',
'town',
'countryCode',
'postalCode',
'comment',
]
const mappings = ref<ConnectionDescriptor[]>([
{
id: 'first_name>givenName',
from: { blockId: 'source', portId: 'first_name' },
to: { blockId: 'target', portId: 'givenName' },
},
{
id: 'email_address>email',
from: { blockId: 'source', portId: 'email_address' },
to: { blockId: 'target', portId: 'email' },
},
{
id: 'notes>comment',
from: { blockId: 'source', portId: 'notes' },
to: { blockId: 'target', portId: 'comment' },
},
])
const config: VisualLinkerConfig = {
lines: { curve: 'bezier', width: 2, hover: { width: 3 } },
markers: { start: { shape: 'circle' }, end: { shape: 'circle' } },
interaction: { hover: true, selectable: true, clipToScrollParents: 'pin' },
}
const picked = ref<string | null>(null)
function pickSource(field: string) {
picked.value = picked.value === field ? null : field
}
function mapTo(field: string) {
if (!picked.value) return
const id = `${picked.value}>${field}`
if (!mappings.value.some((m) => m.id === id)) {
mappings.value.push({
id,
from: { blockId: 'source', portId: picked.value },
to: { blockId: 'target', portId: field },
})
}
picked.value = null
}
function remove(doomed: ConnectionDescriptor[]) {
const ids = new Set(doomed.map((c) => c.id))
mappings.value = mappings.value.filter((m) => !ids.has(m.id))
}
</script>
<template>
<VisualLinker
class="diagram"
:connections="mappings"
:config="config"
@connection-delete-request="remove"
>
<div v-vl-block="'source'" class="panel">
<h3>CSV columns</h3>
<div
v-for="field in sourceFields"
:key="field"
v-vl-port="{ id: field, side: 'right' }"
class="row"
:class="{ picked: picked === field }"
@click="pickSource(field)"
>
{{ field }}
</div>
</div>
<div v-vl-block="'target'" class="panel">
<h3>Database fields</h3>
<div
v-for="field in targetFields"
:key="field"
v-vl-port="{ id: field, side: 'left' }"
class="row"
@click="mapTo(field)"
>
{{ field }}
</div>
</div>
</VisualLinker>
</template>Nuxt
The component works unchanged: there is nothing to run on the server beyond the markup, and the engine draws on the client after mounting. The import line for VisualLinker, vVlBlock and vVlPort can be dropped, since the module registers all three.
How it works
- Panels are blocks, rows are ports. Both panels are registered as blocks, and each row is a port of its panel:
targetis the row,sideis'right'for the source rows and'left'for the target rows. A mapping is a connection from a row to a row, named byportId. - A row that scrolled out of view.
clipToScrollParents: 'pin'pulls the end of the line to the edge of the visible part of the list, and drops the marker of that end, so the line reads as continuing off-screen. The engine measures from the row itself, so it knows which scrolling ancestor hides it. - Points on the rows. Each point sits on the edge of its own row. Setting
anchorBlockIdto the panel would put the points on the panel's border instead, past the scrollbar, and rows scrolled out of view would be pinned in the same way. See Scrolling Containers. - Making a match. A click on a source row remembers it, and a click on a target row adds the connection. A selected line (
selectable: true) is removed with Delete throughconnection:delete-request. - Lines follow scrolling. The engine listens to the scrolling of the panels, so lines move with the rows and no manual refresh is needed.
Variations
- Hide instead of pin.
clipToScrollParents: 'hide'removes the whole line while either of its rows is out of view, which keeps long lists calmer. - Auto-match by name. Fill
mappingsfrom the pairs of names that are equal after normalising case and underscores; the user then corrects only what is wrong. - Style the pinned lines. The
layoutevent flagsfromClippedandtoClippedon each connection, for drawing your own arrow at the edge. See Scrolling Containers.