Схема базы данных (ER-диаграмма)
Таблицы — блоки, столбцы — порты: каждая связь выходит ровно из той строки, где лежит внешний ключ, и приходит в строку первичного ключа другой таблицы. Таблицы перетаскиваются за заголовок, линии идут под прямым углом, а наведение на таблицу делает толще все её связи.
Что здесь используется:
- Порты на строках —
targetнаправляет порт на один элемент внутри блока. - Разведение соединений по порту — связи с одной стороны таблицы не накладываются.
- Подписи —
Nи1на двух концах. - Визуальные состояния —
highlight, вид связи, пока курсор над одной из её таблиц. - Перетаскивание с рукояткой, сеткой и границами.
Чистый 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', чтобы линии шли по диагонали, а не под прямым углом.