Database Schema (ER Diagram)
Tables as blocks, columns as ports: each relation leaves from the exact row that holds the foreign key and lands on the primary key row of the other table. Tables are dragged by their title, lines turn at right angles, and pointing at a table thickens all of its relations.
What it uses:
- Ports on a row —
targetpoints a port at one element inside the block. - Port spreading — relations that share a side of a table do not overlap.
- Labels — the
Nand1at the two ends. - Visual states —
highlight, the look of a relation while the pointer is over one of its tables. - Drag & drop with a handle, a grid and bounds.
Plain JavaScript
The container is an empty element; the script fills it with tables.
html
<div id="diagram"></div>The styles position each table inside the container. The diagram needs only position: relative on the container and position: absolute on the blocks.
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
Rows become ports with v-vl-port, tables become blocks with v-vl-block. The styles are the same as above, in a <style scoped> block.
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
The component works as it is. The module registers <VisualLinker> and both directives, so the import line can go:
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>How it works
- A port per column.
target: '[data-field="user_id"]'is a selector resolved inside the table; a connection names the port withportId. In Vue,v-vl-porton the row does the same, and the port belongs to the nearest enclosing block. - Sides are chosen for you.
side: ['left', 'right']lets the engine pick whichever of the two faces the other table, and never exits through the top or the bottom of a row. - Spreading.
ports: { spread: true }gives connections that share a side their own point, so two relations into one table arrive side by side. - Cardinality. A label with
position: 'start'sits just inside the line's start, at the foreign key, and'end'just inside its end, at the primary key. - Drag by the title.
dragHandle: '.table-title'limits a drag to the title bar, so the rows stay free for clicks and text selection.gridsnaps tables to a grid andbounds: 'container'keeps them inside the diagram. - Highlighting. The pointer over any part of a table puts every relation of that table into the
highlightstate, here a thicker line. The pointer over a line puts only that line intohover. Both are off by default and are turned on byinteraction: { hover: true, highlight: true }.
Variations
- Select a relation. Add
interaction: { selectable: true }and readconnection:selectionchange(@connection-selectionchangein Vue) to show the details of the chosen relation. See Selection & Accessibility. - Build it from a real schema. Fill
tablesandrelationsfrom your database introspection. Nothing else changes. - Straight lines. Set
curve: 'straight'for diagonal lines instead of right-angle turns.