UI Components Hard
Schema Diagram
Visual ER / database schema diagram built with SVG — tables, columns, data types, and relationship arrows. No libraries.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f9fafb;
color: #111;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.demo {
display: flex;
flex-direction: column;
height: 100vh;
}
.sd-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.sd-title {
font-size: 15px;
font-weight: 800;
}
.sd-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.sd-btn {
padding: 6px 14px;
background: #f3f4f6;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: #374151;
}
.sd-btn:hover {
background: #e5e7eb;
}
.sd-hint {
font-size: 12px;
color: #9ca3af;
}
.sd-canvas {
flex: 1;
position: relative;
overflow: hidden;
background: #f9fafb;
background-image: radial-gradient(circle, #d1d5db 1px, transparent 1px);
background-size: 24px 24px;
}
.sd-svg {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Table node */
.sd-table {
position: absolute;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
min-width: 200px;
cursor: grab;
user-select: none;
transition: box-shadow 0.15s;
}
.sd-table:active {
cursor: grabbing;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);
}
.sd-table.selected {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.sd-table-header {
padding: 10px 14px;
background: #6366f1;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
gap: 8px;
}
.sd-table-icon {
font-size: 14px;
}
.sd-table-name {
font-size: 13px;
font-weight: 800;
color: #fff;
}
.sd-col {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-top: 1px solid #f3f4f6;
font-size: 12px;
}
.sd-col:hover {
background: #f9fafb;
}
.col-key {
width: 14px;
font-size: 11px;
flex-shrink: 0;
}
.col-pk {
color: #f59e0b;
}
.col-fk {
color: #6366f1;
}
.col-name {
flex: 1;
font-weight: 600;
color: #111827;
}
.col-type {
font-size: 11px;
color: #9ca3af;
font-family: Menlo, monospace;
}
.col-nullable {
font-size: 10px;
color: #d1d5db;
margin-left: auto;
}
/* Relationship line labels */
.rel-label {
font-size: 10px;
fill: #6b7280;
}const SCHEMA = [
{
id: "users",
name: "users",
icon: "👤",
x: 40,
y: 60,
cols: [
{ name: "id", type: "uuid", key: "pk" },
{ name: "email", type: "varchar(255)" },
{ name: "name", type: "varchar(100)" },
{ name: "created_at", type: "timestamp", nullable: true },
],
},
{
id: "orders",
name: "orders",
icon: "📦",
x: 320,
y: 40,
cols: [
{ name: "id", type: "uuid", key: "pk" },
{ name: "user_id", type: "uuid", key: "fk" },
{ name: "status", type: "varchar(20)" },
{ name: "total", type: "numeric(10,2)" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "order_items",
name: "order_items",
icon: "🗂",
x: 580,
y: 40,
cols: [
{ name: "id", type: "uuid", key: "pk" },
{ name: "order_id", type: "uuid", key: "fk" },
{ name: "product_id", type: "uuid", key: "fk" },
{ name: "quantity", type: "integer" },
{ name: "unit_price", type: "numeric(10,2)" },
],
},
{
id: "products",
name: "products",
icon: "🏷",
x: 580,
y: 280,
cols: [
{ name: "id", type: "uuid", key: "pk" },
{ name: "name", type: "varchar(200)" },
{ name: "price", type: "numeric(10,2)" },
{ name: "stock", type: "integer" },
{ name: "category_id", type: "uuid", key: "fk" },
],
},
{
id: "categories",
name: "categories",
icon: "🗃",
x: 320,
y: 280,
cols: [
{ name: "id", type: "uuid", key: "pk" },
{ name: "name", type: "varchar(100)" },
{ name: "slug", type: "varchar(100)" },
],
},
];
const RELATIONS = [
{ from: "orders", fromCol: "user_id", to: "users", toCol: "id", label: "1:N" },
{ from: "order_items", fromCol: "order_id", to: "orders", toCol: "id", label: "1:N" },
{ from: "order_items", fromCol: "product_id", to: "products", toCol: "id", label: "1:N" },
{ from: "products", fromCol: "category_id", to: "categories", toCol: "id", label: "N:1" },
];
const canvas = document.getElementById("sdCanvas");
const container = document.getElementById("tableContainer");
const svgEl = document.getElementById("sdSvg");
const linesG = document.getElementById("lines");
const tableEls = {};
const positions = {};
function renderTable(t) {
const el = document.createElement("div");
el.className = "sd-table";
el.id = `tbl-${t.id}`;
el.style.left = t.x + "px";
el.style.top = t.y + "px";
positions[t.id] = { x: t.x, y: t.y };
let html = `<div class="sd-table-header"><span class="sd-table-icon">${t.icon}</span><span class="sd-table-name">${t.name}</span></div>`;
t.cols.forEach((col) => {
const keyHtml =
col.key === "pk"
? `<span class="col-key col-pk">🔑</span>`
: col.key === "fk"
? `<span class="col-key col-fk">🔗</span>`
: `<span class="col-key"></span>`;
const nullable = col.nullable ? `<span class="col-nullable">?</span>` : "";
html += `<div class="sd-col">${keyHtml}<span class="col-name">${col.name}</span><span class="col-type">${col.type}</span>${nullable}</div>`;
});
el.innerHTML = html;
// Drag
let dragging = false,
ox = 0,
oy = 0;
el.addEventListener("mousedown", (e) => {
dragging = true;
ox = e.clientX - positions[t.id].x;
oy = e.clientY - positions[t.id].y;
el.classList.add("selected");
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const x = e.clientX - ox;
const y = e.clientY - oy;
positions[t.id] = { x, y };
el.style.left = x + "px";
el.style.top = y + "px";
renderLines();
});
window.addEventListener("mouseup", () => {
if (dragging) {
dragging = false;
el.classList.remove("selected");
}
});
container.appendChild(el);
tableEls[t.id] = el;
}
function getCenter(id) {
const el = tableEls[id];
const { x, y } = positions[id];
return { x: x + el.offsetWidth / 2, y: y + el.offsetHeight / 2 };
}
function renderLines() {
linesG.innerHTML = "";
RELATIONS.forEach((rel) => {
const fc = getCenter(rel.from);
const tc = getCenter(rel.to);
const mx = (fc.x + tc.x) / 2;
const my = (fc.y + tc.y) / 2;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const d = `M ${fc.x} ${fc.y} C ${mx} ${fc.y}, ${mx} ${tc.y}, ${tc.x} ${tc.y}`;
path.setAttribute("d", d);
path.setAttribute("stroke", "#9ca3af");
path.setAttribute("stroke-width", "1.5");
path.setAttribute("fill", "none");
path.setAttribute("marker-end", "url(#arrow)");
linesG.appendChild(path);
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", mx);
text.setAttribute("y", (fc.y + tc.y) / 2 - 4);
text.setAttribute("text-anchor", "middle");
text.className.baseVal = "rel-label";
text.textContent = rel.label;
linesG.appendChild(text);
});
}
SCHEMA.forEach(renderTable);
// Resize observer to re-render lines when tables are sized
const ro = new ResizeObserver(renderLines);
Object.values(tableEls).forEach((el) => ro.observe(el));
renderLines();
// Fit button
document.getElementById("fitBtn").addEventListener("click", () => {
SCHEMA.forEach((t, i) => {
const row = Math.floor(i / 3);
const col = i % 3;
positions[t.id] = { x: 40 + col * 260, y: 60 + row * 220 };
tableEls[t.id].style.left = positions[t.id].x + "px";
tableEls[t.id].style.top = positions[t.id].y + "px";
});
renderLines();
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schema Diagram</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div class="sd-toolbar">
<span class="sd-title">E-Commerce Schema</span>
<div class="sd-actions">
<button class="sd-btn" id="fitBtn">Fit view</button>
<span class="sd-hint">Drag tables to reposition</span>
</div>
</div>
<div class="sd-canvas" id="sdCanvas">
<svg class="sd-svg" id="sdSvg" width="100%" height="100%">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#9ca3af" />
</marker>
</defs>
<!-- Relationship lines rendered by JS -->
<g id="lines"></g>
</svg>
<!-- Table nodes rendered by JS -->
<div id="tableContainer"></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
interface Column {
name: string;
type: string;
pk?: boolean;
fk?: boolean;
nullable?: boolean;
}
interface Table {
id: string;
name: string;
color: string;
x: number;
y: number;
columns: Column[];
}
interface Relation {
from: string; // tableId.columnName
to: string;
label?: string;
}
const TABLES: Table[] = [
{
id: "users",
name: "users",
color: "#58a6ff",
x: 60,
y: 80,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "email", type: "text", nullable: false },
{ name: "name", type: "text", nullable: true },
{ name: "role", type: "enum", nullable: false },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "posts",
name: "posts",
color: "#7ee787",
x: 400,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "title", type: "text" },
{ name: "body", type: "text", nullable: true },
{ name: "published", type: "bool" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "comments",
name: "comments",
color: "#e3b341",
x: 400,
y: 330,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "post_id", type: "uuid", fk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "body", type: "text" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "tags",
name: "tags",
color: "#bc8cff",
x: 720,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "name", type: "text" },
{ name: "slug", type: "text" },
],
},
{
id: "post_tags",
name: "post_tags",
color: "#ff7b72",
x: 720,
y: 280,
columns: [
{ name: "post_id", type: "uuid", pk: true, fk: true },
{ name: "tag_id", type: "uuid", pk: true, fk: true },
],
},
];
const RELATIONS: Relation[] = [
{ from: "posts.user_id", to: "users.id", label: "N:1" },
{ from: "comments.post_id", to: "posts.id", label: "N:1" },
{ from: "comments.user_id", to: "users.id", label: "N:1" },
{ from: "post_tags.post_id", to: "posts.id", label: "N:1" },
{ from: "post_tags.tag_id", to: "tags.id", label: "N:1" },
];
const TABLE_W = 200;
const ROW_H = 28;
const HEADER_H = 36;
function tableHeight(t: Table) {
return HEADER_H + t.columns.length * ROW_H;
}
function colY(t: Table, colName: string) {
const idx = t.columns.findIndex((c) => c.name === colName);
return t.y + HEADER_H + idx * ROW_H + ROW_H / 2;
}
type Point = { x: number; y: number };
function bezierPath(a: Point, b: Point) {
const cx = (a.x + b.x) / 2;
return `M ${a.x} ${a.y} C ${cx} ${a.y}, ${cx} ${b.y}, ${b.x} ${b.y}`;
}
function RelationLine({
fromTable,
toTable,
fromCol,
toCol,
label,
active,
animated,
}: {
fromTable: Table;
toTable: Table;
fromCol: string;
toCol: string;
label?: string;
active: boolean;
animated: boolean;
}) {
const fx = fromTable.x + TABLE_W;
const fy = colY(fromTable, fromCol);
const tx = toTable.x;
const ty = colY(toTable, toCol);
const path = bezierPath({ x: fx, y: fy }, { x: tx, y: ty });
const mid = { x: (fx + tx) / 2, y: (fy + ty) / 2 };
return (
<g>
<path
d={path}
fill="none"
stroke={active ? "#58a6ff" : "#30363d"}
strokeWidth={active ? 2 : 1.5}
strokeDasharray={animated ? "6 3" : undefined}
className={animated ? "animate-dash" : undefined}
opacity={active ? 1 : 0.6}
/>
{label && (
<text
x={mid.x}
y={mid.y - 6}
fill={active ? "#58a6ff" : "#484f58"}
fontSize={10}
textAnchor="middle"
fontFamily="monospace"
>
{label}
</text>
)}
</g>
);
}
function TableNode({
table,
onDrag,
selected,
onClick,
}: {
table: Table;
onDrag: (id: string, dx: number, dy: number) => void;
selected: boolean;
onClick: (id: string) => void;
}) {
const dragRef = useRef<{ startX: number; startY: number } | null>(null);
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
dragRef.current = { startX: e.clientX, startY: e.clientY };
const onMove = (me: MouseEvent) => {
if (!dragRef.current) return;
onDrag(table.id, me.clientX - dragRef.current.startX, me.clientY - dragRef.current.startY);
dragRef.current = { startX: me.clientX, startY: me.clientY };
};
const onUp = () => {
dragRef.current = null;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
onClick(table.id);
};
const h = tableHeight(table);
return (
<g
transform={`translate(${table.x}, ${table.y})`}
onMouseDown={handleMouseDown}
style={{ cursor: "grab" }}
>
{/* Shadow */}
<rect x={3} y={3} width={TABLE_W} height={h} rx={8} fill="rgba(0,0,0,0.4)" />
{/* Body */}
<rect
width={TABLE_W}
height={h}
rx={8}
fill="#161b22"
stroke={selected ? table.color : "#30363d"}
strokeWidth={selected ? 2 : 1}
/>
{/* Header */}
<rect width={TABLE_W} height={HEADER_H} rx={8} fill={table.color + "22"} />
<rect y={HEADER_H - 8} width={TABLE_W} height={8} fill={table.color + "22"} />
<rect
y={HEADER_H - 1}
width={TABLE_W}
height={1}
fill={selected ? table.color : "#30363d"}
opacity={0.6}
/>
{/* Table name */}
<text
x={TABLE_W / 2}
y={HEADER_H / 2 + 5}
textAnchor="middle"
fill={table.color}
fontSize={13}
fontWeight="bold"
fontFamily="monospace"
>
{table.name}
</text>
{/* Columns */}
{table.columns.map((col, i) => {
const cy = HEADER_H + i * ROW_H;
return (
<g key={col.name}>
{i % 2 === 1 && (
<rect y={cy} width={TABLE_W} height={ROW_H} fill="rgba(255,255,255,0.02)" />
)}
{/* PK/FK indicator */}
{(col.pk || col.fk) && (
<text
x={10}
y={cy + ROW_H / 2 + 4}
fill={col.pk ? "#e3b341" : "#bc8cff"}
fontSize={9}
fontFamily="monospace"
>
{col.pk ? "PK" : "FK"}
</text>
)}
<text
x={col.pk || col.fk ? 34 : 10}
y={cy + ROW_H / 2 + 4}
fill={col.nullable ? "#8b949e" : "#e6edf3"}
fontSize={11}
fontFamily="monospace"
>
{col.name}
</text>
<text
x={TABLE_W - 8}
y={cy + ROW_H / 2 + 4}
textAnchor="end"
fill="#484f58"
fontSize={10}
fontFamily="monospace"
>
{col.type}
</text>
</g>
);
})}
</g>
);
}
export default function SchemaDiagramRC() {
const [tables, setTables] = useState(TABLES);
const [selected, setSelected] = useState<string | null>(null);
const [animated, setAnimated] = useState(true);
const handleDrag = useCallback((id: string, dx: number, dy: number) => {
setTables((prev) => prev.map((t) => (t.id === id ? { ...t, x: t.x + dx, y: t.y + dy } : t)));
}, []);
const canvasH = Math.max(...tables.map((t) => t.y + tableHeight(t))) + 80;
const canvasW = Math.max(...tables.map((t) => t.x + TABLE_W)) + 80;
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex flex-col items-center">
<style>{`
@keyframes dash { to { stroke-dashoffset: -18; } }
.animate-dash { animation: dash 0.8s linear infinite; }
`}</style>
<div className="w-full max-w-[1000px] space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-[15px] font-bold text-[#e6edf3]">Schema Diagram</h2>
<div className="flex items-center gap-2">
<span className="text-[11px] text-[#484f58]">Drag tables to reposition</span>
<button
onClick={() => setAnimated((v) => !v)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold border transition-colors ${
animated
? "bg-[#58a6ff]/10 border-[#58a6ff]/30 text-[#58a6ff]"
: "border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]"
}`}
>
{animated ? "Animated" : "Static"}
</button>
<button
onClick={() => setTables(TABLES)}
className="px-2.5 py-1 rounded-lg text-[11px] border border-[#30363d] text-[#8b949e] hover:text-[#e6edf3] transition-colors"
>
Reset
</button>
</div>
</div>
<div className="bg-[#0d1117] border border-[#30363d] rounded-xl overflow-auto">
<svg width={canvasW} height={canvasH} className="block" onClick={() => setSelected(null)}>
{/* Grid */}
<defs>
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#21262d" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Relations (behind tables) */}
{RELATIONS.map((rel) => {
const [fid, fcol] = rel.from.split(".");
const [tid, tcol] = rel.to.split(".");
const ft = tables.find((t) => t.id === fid);
const tt = tables.find((t) => t.id === tid);
if (!ft || !tt) return null;
const active = selected === fid || selected === tid;
return (
<RelationLine
key={rel.from}
fromTable={ft}
toTable={tt}
fromCol={fcol}
toCol={tcol}
label={rel.label}
active={active}
animated={animated}
/>
);
})}
{/* Tables */}
{tables.map((t) => (
<TableNode
key={t.id}
table={t}
onDrag={handleDrag}
selected={selected === t.id}
onClick={setSelected}
/>
))}
</svg>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 text-[11px] text-[#8b949e]">
<span className="flex items-center gap-1.5">
<span className="font-mono font-bold text-[#e3b341]">PK</span> Primary key
</span>
<span className="flex items-center gap-1.5">
<span className="font-mono font-bold text-[#bc8cff]">FK</span> Foreign key
</span>
<span className="flex items-center gap-1.5">
<span className="w-6 h-px border-t-2 border-dashed border-[#30363d] inline-block" />{" "}
Relation
</span>
<span className="flex items-center gap-1.5">
<span className="w-6 h-px border-t-2 border-[#58a6ff] inline-block" /> Selected relation
</span>
</div>
</div>
</div>
);
}<script setup>
import { ref, computed } from "vue";
const TABLES_INIT = [
{
id: "users",
name: "users",
color: "#58a6ff",
x: 60,
y: 80,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "email", type: "text", nullable: false },
{ name: "name", type: "text", nullable: true },
{ name: "role", type: "enum", nullable: false },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "posts",
name: "posts",
color: "#7ee787",
x: 400,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "title", type: "text" },
{ name: "body", type: "text", nullable: true },
{ name: "published", type: "bool" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "comments",
name: "comments",
color: "#e3b341",
x: 400,
y: 330,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "post_id", type: "uuid", fk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "body", type: "text" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "tags",
name: "tags",
color: "#bc8cff",
x: 720,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "name", type: "text" },
{ name: "slug", type: "text" },
],
},
{
id: "post_tags",
name: "post_tags",
color: "#ff7b72",
x: 720,
y: 280,
columns: [
{ name: "post_id", type: "uuid", pk: true, fk: true },
{ name: "tag_id", type: "uuid", pk: true, fk: true },
],
},
];
const RELATIONS = [
{ from: "posts.user_id", to: "users.id", label: "N:1" },
{ from: "comments.post_id", to: "posts.id", label: "N:1" },
{ from: "comments.user_id", to: "users.id", label: "N:1" },
{ from: "post_tags.post_id", to: "posts.id", label: "N:1" },
{ from: "post_tags.tag_id", to: "tags.id", label: "N:1" },
];
const TABLE_W = 200;
const ROW_H = 28;
const HEADER_H = 36;
const tables = ref(TABLES_INIT.map((t) => ({ ...t })));
const selected = ref(null);
const animated = ref(true);
function tableHeight(t) {
return HEADER_H + t.columns.length * ROW_H;
}
function colY(t, colName) {
const idx = t.columns.findIndex((c) => c.name === colName);
return t.y + HEADER_H + idx * ROW_H + ROW_H / 2;
}
function bezierPath(a, b) {
const cx = (a.x + b.x) / 2;
return `M ${a.x} ${a.y} C ${cx} ${a.y}, ${cx} ${b.y}, ${b.x} ${b.y}`;
}
const canvasH = computed(() => Math.max(...tables.value.map((t) => t.y + tableHeight(t))) + 80);
const canvasW = computed(() => Math.max(...tables.value.map((t) => t.x + TABLE_W)) + 80);
function getRelationData(rel) {
const [fid, fcol] = rel.from.split(".");
const [tid, tcol] = rel.to.split(".");
const ft = tables.value.find((t) => t.id === fid);
const tt = tables.value.find((t) => t.id === tid);
if (!ft || !tt) return null;
const fx = ft.x + TABLE_W;
const fy = colY(ft, fcol);
const tx = tt.x;
const ty = colY(tt, tcol);
const path = bezierPath({ x: fx, y: fy }, { x: tx, y: ty });
const mid = { x: (fx + tx) / 2, y: (fy + ty) / 2 };
const active = selected.value === fid || selected.value === tid;
return { path, mid, label: rel.label, active };
}
function handleTableMouseDown(e, tableId) {
e.stopPropagation();
selected.value = tableId;
let lastX = e.clientX;
let lastY = e.clientY;
const onMove = (me) => {
const dx = me.clientX - lastX;
const dy = me.clientY - lastY;
lastX = me.clientX;
lastY = me.clientY;
tables.value = tables.value.map((t) =>
t.id === tableId ? { ...t, x: t.x + dx, y: t.y + dy } : t
);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
function resetTables() {
tables.value = TABLES_INIT.map((t) => ({ ...t }));
}
</script>
<template>
<div style="min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; flex-direction: column; align-items: center; font-family: system-ui, -apple-system, sans-serif;">
<div style="width: 100%; max-width: 1000px; display: flex; flex-direction: column; gap: 0.75rem;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<h2 style="font-size: 15px; font-weight: 700; color: #e6edf3;">Schema Diagram</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 11px; color: #484f58;">Drag tables to reposition</span>
<button
@click="animated = !animated"
:style="{
padding: '0.25rem 0.625rem',
borderRadius: '0.5rem',
fontSize: '11px',
fontWeight: 600,
border: `1px solid ${animated ? 'rgba(88,166,255,0.3)' : '#30363d'}`,
background: animated ? 'rgba(88,166,255,0.1)' : 'transparent',
color: animated ? '#58a6ff' : '#8b949e',
cursor: 'pointer',
}"
>
{{ animated ? "Animated" : "Static" }}
</button>
<button
@click="resetTables"
style="padding: 0.25rem 0.625rem; border-radius: 0.5rem; font-size: 11px; border: 1px solid #30363d; color: #8b949e; background: transparent; cursor: pointer;"
>
Reset
</button>
</div>
</div>
<div style="background: #0d1117; border: 1px solid #30363d; border-radius: 0.75rem; overflow: auto;">
<svg
:width="canvasW"
:height="canvasH"
style="display: block;"
@click="selected = null"
>
<defs>
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#21262d" stroke-width="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
<template v-for="rel in RELATIONS" :key="rel.from">
<g v-if="getRelationData(rel)">
<path
:d="getRelationData(rel).path"
fill="none"
:stroke="getRelationData(rel).active ? '#58a6ff' : '#30363d'"
:stroke-width="getRelationData(rel).active ? 2 : 1.5"
:stroke-dasharray="animated ? '6 3' : undefined"
:class="animated ? 'animate-dash' : ''"
:opacity="getRelationData(rel).active ? 1 : 0.6"
/>
<text
v-if="getRelationData(rel).label"
:x="getRelationData(rel).mid.x"
:y="getRelationData(rel).mid.y - 6"
:fill="getRelationData(rel).active ? '#58a6ff' : '#484f58'"
font-size="10"
text-anchor="middle"
font-family="monospace"
>{{ getRelationData(rel).label }}</text>
</g>
</template>
<g
v-for="table in tables"
:key="table.id"
:transform="`translate(${table.x}, ${table.y})`"
@mousedown="(e) => handleTableMouseDown(e, table.id)"
style="cursor: grab;"
>
<rect x="3" y="3" :width="TABLE_W" :height="tableHeight(table)" rx="8" fill="rgba(0,0,0,0.4)" />
<rect :width="TABLE_W" :height="tableHeight(table)" rx="8" fill="#161b22" :stroke="selected === table.id ? table.color : '#30363d'" :stroke-width="selected === table.id ? 2 : 1" />
<rect :width="TABLE_W" :height="HEADER_H" rx="8" :fill="table.color + '22'" />
<rect :y="HEADER_H - 8" :width="TABLE_W" height="8" :fill="table.color + '22'" />
<rect :y="HEADER_H - 1" :width="TABLE_W" height="1" :fill="selected === table.id ? table.color : '#30363d'" opacity="0.6" />
<text :x="TABLE_W / 2" :y="HEADER_H / 2 + 5" text-anchor="middle" :fill="table.color" font-size="13" font-weight="bold" font-family="monospace">{{ table.name }}</text>
<g v-for="(col, i) in table.columns" :key="col.name">
<rect v-if="i % 2 === 1" :y="HEADER_H + i * ROW_H" :width="TABLE_W" :height="ROW_H" fill="rgba(255,255,255,0.02)" />
<text v-if="col.pk || col.fk" x="10" :y="HEADER_H + i * ROW_H + ROW_H / 2 + 4" :fill="col.pk ? '#e3b341' : '#bc8cff'" font-size="9" font-family="monospace">{{ col.pk ? "PK" : "FK" }}</text>
<text :x="col.pk || col.fk ? 34 : 10" :y="HEADER_H + i * ROW_H + ROW_H / 2 + 4" :fill="col.nullable ? '#8b949e' : '#e6edf3'" font-size="11" font-family="monospace">{{ col.name }}</text>
<text :x="TABLE_W - 8" :y="HEADER_H + i * ROW_H + ROW_H / 2 + 4" text-anchor="end" fill="#484f58" font-size="10" font-family="monospace">{{ col.type }}</text>
</g>
</g>
</svg>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 1rem; font-size: 11px; color: #8b949e;">
<span style="display: flex; align-items: center; gap: 6px;">
<span style="font-family: monospace; font-weight: 700; color: #e3b341;">PK</span> Primary key
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="font-family: monospace; font-weight: 700; color: #bc8cff;">FK</span> Foreign key
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="width: 24px; height: 1px; border-top: 2px dashed #30363d; display: inline-block;" /> Relation
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="width: 24px; height: 1px; border-top: 2px solid #58a6ff; display: inline-block;" /> Selected
</span>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes dash { to { stroke-dashoffset: -18; } }
.animate-dash { animation: dash 0.8s linear infinite; }
</style><script>
const TABLES_INIT = [
{
id: "users",
name: "users",
color: "#58a6ff",
x: 60,
y: 80,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "email", type: "text", nullable: false },
{ name: "name", type: "text", nullable: true },
{ name: "role", type: "enum", nullable: false },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "posts",
name: "posts",
color: "#7ee787",
x: 400,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "title", type: "text" },
{ name: "body", type: "text", nullable: true },
{ name: "published", type: "bool" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "comments",
name: "comments",
color: "#e3b341",
x: 400,
y: 330,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "post_id", type: "uuid", fk: true },
{ name: "user_id", type: "uuid", fk: true },
{ name: "body", type: "text" },
{ name: "created_at", type: "timestamp" },
],
},
{
id: "tags",
name: "tags",
color: "#bc8cff",
x: 720,
y: 60,
columns: [
{ name: "id", type: "uuid", pk: true },
{ name: "name", type: "text" },
{ name: "slug", type: "text" },
],
},
{
id: "post_tags",
name: "post_tags",
color: "#ff7b72",
x: 720,
y: 280,
columns: [
{ name: "post_id", type: "uuid", pk: true, fk: true },
{ name: "tag_id", type: "uuid", pk: true, fk: true },
],
},
];
const RELATIONS = [
{ from: "posts.user_id", to: "users.id", label: "N:1" },
{ from: "comments.post_id", to: "posts.id", label: "N:1" },
{ from: "comments.user_id", to: "users.id", label: "N:1" },
{ from: "post_tags.post_id", to: "posts.id", label: "N:1" },
{ from: "post_tags.tag_id", to: "tags.id", label: "N:1" },
];
const TABLE_W = 200;
const ROW_H = 28;
const HEADER_H = 36;
let tables = TABLES_INIT.map((t) => ({ ...t }));
let selected = null;
let animated = true;
function tableHeight(t) {
return HEADER_H + t.columns.length * ROW_H;
}
function colY(t, colName) {
const idx = t.columns.findIndex((c) => c.name === colName);
return t.y + HEADER_H + idx * ROW_H + ROW_H / 2;
}
function bezierPath(a, b) {
const cx = (a.x + b.x) / 2;
return `M ${a.x} ${a.y} C ${cx} ${a.y}, ${cx} ${b.y}, ${b.x} ${b.y}`;
}
function getRelationPath(rel) {
const [fid, fcol] = rel.from.split(".");
const [tid, tcol] = rel.to.split(".");
const ft = tables.find((t) => t.id === fid);
const tt = tables.find((t) => t.id === tid);
if (!ft || !tt) return null;
const fx = ft.x + TABLE_W;
const fy = colY(ft, fcol);
const tx = tt.x;
const ty = colY(tt, tcol);
const path = bezierPath({ x: fx, y: fy }, { x: tx, y: ty });
const mid = { x: (fx + tx) / 2, y: (fy + ty) / 2 };
const active = selected === fid || selected === tid;
return { path, mid, label: rel.label, active };
}
$: canvasH = Math.max(...tables.map((t) => t.y + tableHeight(t))) + 80;
$: canvasW = Math.max(...tables.map((t) => t.x + TABLE_W)) + 80;
function handleTableMouseDown(e, tableId) {
e.stopPropagation();
selected = tableId;
const startX = e.clientX;
const startY = e.clientY;
const onMove = (me) => {
const dx = me.clientX - startX;
const dy = me.clientY - startY;
tables = tables.map((t) =>
t.id === tableId
? { ...t, x: t.x + (me.clientX - startX), y: t.y + (me.clientY - startY) }
: t
);
// Update start for next delta
Object.defineProperty(onMove, "_startX", { value: me.clientX, writable: true });
Object.defineProperty(onMove, "_startY", { value: me.clientY, writable: true });
};
let lastX = e.clientX;
let lastY = e.clientY;
const onMoveTracked = (me) => {
const dx = me.clientX - lastX;
const dy = me.clientY - lastY;
lastX = me.clientX;
lastY = me.clientY;
tables = tables.map((t) => (t.id === tableId ? { ...t, x: t.x + dx, y: t.y + dy } : t));
};
const onUp = () => {
window.removeEventListener("mousemove", onMoveTracked);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMoveTracked);
window.addEventListener("mouseup", onUp);
}
function resetTables() {
tables = TABLES_INIT.map((t) => ({ ...t }));
}
</script>
<svelte:head>
<style>
@keyframes dash { to { stroke-dashoffset: -18; } }
.animate-dash { animation: dash 0.8s linear infinite; }
</style>
</svelte:head>
<div style="min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; flex-direction: column; align-items: center; font-family: system-ui, -apple-system, sans-serif;">
<div style="width: 100%; max-width: 1000px; display: flex; flex-direction: column; gap: 0.75rem;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<h2 style="font-size: 15px; font-weight: 700; color: #e6edf3;">Schema Diagram</h2>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 11px; color: #484f58;">Drag tables to reposition</span>
<button
on:click={() => (animated = !animated)}
style="padding: 0.25rem 0.625rem; border-radius: 0.5rem; font-size: 11px; font-weight: 600; border: 1px solid {animated ? 'rgba(88,166,255,0.3)' : '#30363d'}; background: {animated ? 'rgba(88,166,255,0.1)' : 'transparent'}; color: {animated ? '#58a6ff' : '#8b949e'}; cursor: pointer;"
>
{animated ? "Animated" : "Static"}
</button>
<button
on:click={resetTables}
style="padding: 0.25rem 0.625rem; border-radius: 0.5rem; font-size: 11px; border: 1px solid #30363d; color: #8b949e; background: transparent; cursor: pointer;"
>
Reset
</button>
</div>
</div>
<div style="background: #0d1117; border: 1px solid #30363d; border-radius: 0.75rem; overflow: auto;">
<svg
width={canvasW}
height={canvasH}
style="display: block;"
on:click={() => (selected = null)}
>
<defs>
<pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M 24 0 L 0 0 0 24" fill="none" stroke="#21262d" stroke-width="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{#each RELATIONS as rel}
{@const r = getRelationPath(rel)}
{#if r}
<g>
<path
d={r.path}
fill="none"
stroke={r.active ? "#58a6ff" : "#30363d"}
stroke-width={r.active ? 2 : 1.5}
stroke-dasharray={animated ? "6 3" : undefined}
class={animated ? "animate-dash" : ""}
opacity={r.active ? 1 : 0.6}
/>
{#if r.label}
<text
x={r.mid.x}
y={r.mid.y - 6}
fill={r.active ? "#58a6ff" : "#484f58"}
font-size="10"
text-anchor="middle"
font-family="monospace"
>{r.label}</text>
{/if}
</g>
{/if}
{/each}
{#each tables as table}
{@const h = tableHeight(table)}
<g
transform="translate({table.x}, {table.y})"
on:mousedown={(e) => handleTableMouseDown(e, table.id)}
style="cursor: grab;"
>
<rect x="3" y="3" width={TABLE_W} height={h} rx="8" fill="rgba(0,0,0,0.4)" />
<rect width={TABLE_W} height={h} rx="8" fill="#161b22" stroke={selected === table.id ? table.color : "#30363d"} stroke-width={selected === table.id ? 2 : 1} />
<rect width={TABLE_W} height={HEADER_H} rx="8" fill={table.color + "22"} />
<rect y={HEADER_H - 8} width={TABLE_W} height="8" fill={table.color + "22"} />
<rect y={HEADER_H - 1} width={TABLE_W} height="1" fill={selected === table.id ? table.color : "#30363d"} opacity="0.6" />
<text x={TABLE_W / 2} y={HEADER_H / 2 + 5} text-anchor="middle" fill={table.color} font-size="13" font-weight="bold" font-family="monospace">{table.name}</text>
{#each table.columns as col, i}
{@const cy = HEADER_H + i * ROW_H}
<g>
{#if i % 2 === 1}
<rect y={cy} width={TABLE_W} height={ROW_H} fill="rgba(255,255,255,0.02)" />
{/if}
{#if col.pk || col.fk}
<text x="10" y={cy + ROW_H / 2 + 4} fill={col.pk ? "#e3b341" : "#bc8cff"} font-size="9" font-family="monospace">{col.pk ? "PK" : "FK"}</text>
{/if}
<text x={col.pk || col.fk ? 34 : 10} y={cy + ROW_H / 2 + 4} fill={col.nullable ? "#8b949e" : "#e6edf3"} font-size="11" font-family="monospace">{col.name}</text>
<text x={TABLE_W - 8} y={cy + ROW_H / 2 + 4} text-anchor="end" fill="#484f58" font-size="10" font-family="monospace">{col.type}</text>
</g>
{/each}
</g>
{/each}
</svg>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 1rem; font-size: 11px; color: #8b949e;">
<span style="display: flex; align-items: center; gap: 6px;">
<span style="font-family: monospace; font-weight: 700; color: #e3b341;">PK</span> Primary key
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="font-family: monospace; font-weight: 700; color: #bc8cff;">FK</span> Foreign key
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="width: 24px; height: 1px; border-top: 2px dashed #30363d; display: inline-block;" /> Relation
</span>
<span style="display: flex; align-items: center; gap: 6px;">
<span style="width: 24px; height: 1px; border-top: 2px solid #58a6ff; display: inline-block;" /> Selected
</span>
</div>
</div>
</div>Interactive ER diagram rendered with SVG — draggable table nodes, column list with type badges, primary/foreign key indicators, and curved relationship arrows with cardinality labels.