Skip to content

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:

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: target is the row, side is 'right' for the source rows and 'left' for the target rows. A mapping is a connection from a row to a row, named by portId.
  • 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 anchorBlockId to 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 through connection: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 mappings from 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 layout event flags fromClipped and toClipped on each connection, for drawing your own arrow at the edge. See Scrolling Containers.