Skip to content

Схема базы данных (ER-диаграмма) ​

Таблицы — блоки, столбцы — порты: каждая связь выходит ровно из той строки, где лежит внешний ключ, и приходит в строку первичного ключа другой таблицы. Таблицы перетаскиваются за заголовок, линии идут под прямым углом, а наведение на таблицу делает толще все её связи.

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

Чистый JavaScript ​

Контейнер — пустой элемент, таблицы в него добавляет скрипт.

html
<div id="diagram"></div>

Стили размещают каждую таблицу в контейнере. Диаграмме нужны только position: relative у контейнера и position: absolute у блоков.

css
body {
  margin: 0;
  font:
    14px system-ui,
    sans-serif;
  background: #f8fafc;
}
#diagram {
  position: relative;
  height: 420px;
  margin: 16px;
}
.table {
  position: absolute;
  width: 190px;
  background: #fff;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  overflow: hidden;
}
.table-title {
  padding: 8px 12px;
  font-weight: 600;
  background: #e2e8f0;
  cursor: grab;
}
.row {
  padding: 6px 12px;
  border-top: 1px solid #f1f5f9;
}
.row.key {
  font-weight: 600;
}
ts
import { createVisualLinker } from '@macrulez/visual-linker-core'

const tables = [
  { id: 'users', x: 0, y: 20, fields: ['id', 'email', 'name'] },
  { id: 'orders', x: 320, y: 0, fields: ['id', 'user_id', 'status', 'total'] },
  { id: 'order_items', x: 640, y: 40, fields: ['id', 'order_id', 'product_id', 'qty'] },
  { id: 'products', x: 320, y: 230, fields: ['id', 'title', 'price'] },
]

const relations = [
  { from: ['orders', 'user_id'], to: ['users', 'id'] },
  { from: ['order_items', 'order_id'], to: ['orders', 'id'] },
  { from: ['order_items', 'product_id'], to: ['products', 'id'] },
]

const diagram = document.querySelector<HTMLElement>('#diagram')!
diagram.innerHTML = tables
  .map(
    (t) => `
  <div class="table" id="t-${t.id}" style="left:${t.x}px;top:${t.y}px">
    <div class="table-title">${t.id}</div>
    ${t.fields.map((f) => `<div class="row${f.endsWith('id') ? ' key' : ''}" data-field="${f}">${f}</div>`).join('')}
  </div>`,
  )
  .join('')

const linker = createVisualLinker(diagram, {
  lines: {
    curve: 'smoothstep',
    width: 1.5,
    highlight: { width: 2.5 },
    hover: { width: 3 },
  },
  markers: { end: { shape: 'arrow' } },
  ports: { spread: true },
  blocks: { draggable: true, drag: { grid: 10, bounds: 'container' } },
  interaction: { hover: true, highlight: true },
})

linker.setBlocks(
  tables.map((t) => ({
    id: t.id,
    el: diagram.querySelector<HTMLElement>(`#t-${t.id}`)!,
    dragHandle: '.table-title',
    ports: t.fields.map((f) => ({
      id: f,
      target: `[data-field="${f}"]`,
      side: ['left', 'right'] as ('left' | 'right')[],
    })),
  })),
)

linker.setConnections(
  relations.map(({ from, to }) => ({
    id: `${from.join('.')}->${to.join('.')}`,
    from: { blockId: from[0], portId: from[1] },
    to: { blockId: to[0], portId: to[1] },
    labels: [
      { id: 'many', position: 'start', text: 'N' },
      { id: 'one', position: 'end', text: '1' },
    ],
  })),
)

Vue ​

Строки становятся портами через v-vl-port, таблицы — блоками через v-vl-block. Стили те же, что выше, в блоке <style scoped>.

vue
<script setup lang="ts">
import { VisualLinker, vVlBlock, vVlPort } from '@macrulez/visual-linker-vue'
import type { ConnectionDescriptor, VisualLinkerConfig } from '@macrulez/visual-linker-vue'

const tables = [
  { id: 'users', x: 0, y: 20, fields: ['id', 'email', 'name'] },
  { id: 'orders', x: 320, y: 0, fields: ['id', 'user_id', 'status', 'total'] },
  { id: 'order_items', x: 640, y: 40, fields: ['id', 'order_id', 'product_id', 'qty'] },
  { id: 'products', x: 320, y: 230, fields: ['id', 'title', 'price'] },
]

const relations: [[string, string], [string, string]][] = [
  [
    ['orders', 'user_id'],
    ['users', 'id'],
  ],
  [
    ['order_items', 'order_id'],
    ['orders', 'id'],
  ],
  [
    ['order_items', 'product_id'],
    ['products', 'id'],
  ],
]

const connections: ConnectionDescriptor[] = relations.map(([from, to]) => ({
  id: `${from.join('.')}->${to.join('.')}`,
  from: { blockId: from[0], portId: from[1] },
  to: { blockId: to[0], portId: to[1] },
  labels: [
    { id: 'many', position: 'start', text: 'N' },
    { id: 'one', position: 'end', text: '1' },
  ],
}))

const config: VisualLinkerConfig = {
  lines: { curve: 'smoothstep', highlight: { width: 2.5 }, hover: { width: 3 } },
  markers: { end: { shape: 'arrow' } },
  ports: { spread: true },
  blocks: { draggable: true, drag: { grid: 10, bounds: 'container' } },
  interaction: { hover: true, highlight: true },
}
</script>

<template>
  <VisualLinker class="diagram" :connections="connections" :config="config">
    <div
      v-for="table in tables"
      :key="table.id"
      v-vl-block="{ id: table.id, dragHandle: '.table-title' }"
      class="table"
      :style="{ left: `${table.x}px`, top: `${table.y}px` }"
    >
      <div class="table-title">{{ table.id }}</div>
      <div
        v-for="field in table.fields"
        :key="field"
        v-vl-port="{ id: field, side: ['left', 'right'] }"
        class="row"
        :class="{ key: field.endsWith('id') }"
      >
        {{ field }}
      </div>
    </div>
  </VisualLinker>
</template>

Nuxt ​

Компонент работает без изменений. Модуль регистрирует <VisualLinker> и обе директивы, поэтому строку import можно убрать:

vue
<script setup lang="ts">
import type { ConnectionDescriptor, VisualLinkerConfig } from '@macrulez/visual-linker-vue'

const tables = [
  { id: 'users', x: 0, y: 20, fields: ['id', 'email', 'name'] },
  { id: 'orders', x: 320, y: 0, fields: ['id', 'user_id', 'status', 'total'] },
  { id: 'order_items', x: 640, y: 40, fields: ['id', 'order_id', 'product_id', 'qty'] },
  { id: 'products', x: 320, y: 230, fields: ['id', 'title', 'price'] },
]

const relations: [[string, string], [string, string]][] = [
  [
    ['orders', 'user_id'],
    ['users', 'id'],
  ],
  [
    ['order_items', 'order_id'],
    ['orders', 'id'],
  ],
  [
    ['order_items', 'product_id'],
    ['products', 'id'],
  ],
]

const connections: ConnectionDescriptor[] = relations.map(([from, to]) => ({
  id: `${from.join('.')}->${to.join('.')}`,
  from: { blockId: from[0], portId: from[1] },
  to: { blockId: to[0], portId: to[1] },
  labels: [
    { id: 'many', position: 'start', text: 'N' },
    { id: 'one', position: 'end', text: '1' },
  ],
}))

const config: VisualLinkerConfig = {
  lines: { curve: 'smoothstep', highlight: { width: 2.5 }, hover: { width: 3 } },
  markers: { end: { shape: 'arrow' } },
  ports: { spread: true },
  blocks: { draggable: true, drag: { grid: 10, bounds: 'container' } },
  interaction: { hover: true, highlight: true },
}
</script>

<template>
  <VisualLinker class="diagram" :connections="connections" :config="config">
    <div
      v-for="table in tables"
      :key="table.id"
      v-vl-block="{ id: table.id, dragHandle: '.table-title' }"
      class="table"
      :style="{ left: `${table.x}px`, top: `${table.y}px` }"
    >
      <div class="table-title">{{ table.id }}</div>
      <div
        v-for="field in table.fields"
        :key="field"
        v-vl-port="{ id: field, side: ['left', 'right'] }"
        class="row"
        :class="{ key: field.endsWith('id') }"
      >
        {{ field }}
      </div>
    </div>
  </VisualLinker>
</template>

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

  • Порт на каждый столбец. target: '[data-field="user_id"]' — селектор, который разрешается внутри таблицы; соединение называет порт через portId. В Vue то же делает v-vl-port на строке: порт принадлежит ближайшему блоку выше.
  • Стороны выбираются сами. side: ['left', 'right'] позволяет движку взять ту из двух сторон, что смотрит на другую таблицу, и никогда не выходить через верх или низ строки.
  • Разведение. ports: { spread: true } даёт соединениям с одной стороны собственные точки, поэтому две связи в одну таблицу приходят рядом друг с другом.
  • Кратность. Подпись с position: 'start' стоит у начала линии, на внешнем ключе, а 'end' — у конца, на первичном ключе.
  • Перетаскивание за заголовок. dragHandle: '.table-title' ограничивает перетаскивание заголовком, так что строки остаются доступны для кликов и выделения текста. grid притягивает таблицы к сетке, а bounds: 'container' удерживает их внутри диаграммы.
  • Подсветка. Курсор над любой частью таблицы переводит все связи этой таблицы в состояние highlight, здесь — более толстая линия. Курсор над самой линией переводит в hover только её. Оба по умолчанию выключены и включаются через interaction: { hover: true, highlight: true }.

Варианты ​

  • Выбор связи. Добавьте interaction: { selectable: true } и слушайте connection:selectionchange (@connection-selectionchange в Vue), чтобы показывать подробности выбранной связи. См. Выделение и доступность.
  • Диаграмма по настоящей схеме. Заполните tables и relations из интроспекции вашей базы. Больше ничего не меняется.
  • Прямые линии. Задайте curve: 'straight', чтобы линии шли по диагонали, а не под прямым углом.