Skip to content

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 — target points a port at one element inside the block.
  • Port spreading — relations that share a side of a table do not overlap.
  • Labels — the N and 1 at 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 with portId. In Vue, v-vl-port on 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. grid snaps tables to a grid and bounds: 'container' keeps them inside the diagram.
  • Highlighting. The pointer over any part of a table puts every relation of that table into the highlight state, here a thicker line. The pointer over a line puts only that line into hover. Both are off by default and are turned on by interaction: { hover: true, highlight: true }.

Variations ​

  • Select a relation. Add interaction: { selectable: true } and read connection:selectionchange (@connection-selectionchange in Vue) to show the details of the chosen relation. See Selection & Accessibility.
  • Build it from a real schema. Fill tables and relations from your database introspection. Nothing else changes.
  • Straight lines. Set curve: 'straight' for diagonal lines instead of right-angle turns.