Skip to content

Сопоставление полей в списках с прокруткой ​

Два длинных списка рядом — столбцы загруженного файла и поля базы данных — и линии между строками, которые пользователь сопоставил. Списки прокручиваются, поэтому сопоставленная строка может оказаться вне видимой области; её линия при этом остаётся прикреплённой к краю списка и не висит над страницей.

Что здесь используется:

Чистый 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 ​

Стили те же, что выше, в блоке <style scoped>.

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 ​

Компонент работает без изменений: на сервере выполнять нечего, кроме разметки, а движок рисует на клиенте после монтирования. Строку import для VisualLinker, vVlBlock и vVlPort можно убрать — модуль регистрирует все три.

Как это работает ​

  • Панели — блоки, строки — порты. Обе панели зарегистрированы как блоки, а каждая строка — порт своей панели: target — это строка, side — 'right' для строк источника и 'left' для строк приёмника. Сопоставление — это соединение от строки к строке, которое называет порты через portId.
  • Строка ушла за край. clipToScrollParents: 'pin' подтягивает конец линии к краю видимой части списка и убирает маркер этого конца, так что линия выглядит уходящей за экран. Движок измеряет от самой строки, поэтому знает, какой прокручиваемый предок её скрывает.
  • Точки на строках. Каждая точка лежит на краю своей строки. Если задать anchorBlockId панели, точки окажутся на границе панели, за полосой прокрутки, а строки, ушедшие за край, будут прижиматься так же. См. Прокручиваемые контейнеры.
  • Создание сопоставления. Клик по строке источника запоминает её, клик по строке приёмника добавляет соединение. Выделенная линия (selectable: true) удаляется клавишей Delete через connection:delete-request.
  • Линии следуют за прокруткой. Движок слушает прокрутку панелей, поэтому линии двигаются вместе со строками и ручное обновление не нужно.

Варианты ​

  • Скрывать вместо закрепления. clipToScrollParents: 'hide' убирает линию целиком, пока любая из её строк вне видимой области, — длинные списки выглядят спокойнее.
  • Автосопоставление по имени. Заполните mappings парами имён, равных после приведения регистра и подчёркиваний; пользователю остаётся исправить только неверное.
  • Оформление закреплённых линий. Событие layout отмечает fromClipped и toClipped у каждого соединения, чтобы нарисовать свою стрелку у края. См. Прокручиваемые контейнеры.