// Browser entry for the Truss dashboard. Thin DOM/fetch/Mermaid glue over the
// pure, unit-tested modules (selection, generator, viewport math). No build
// step: native ES module; Mermaid is a global from a <script> tag.
import { filterTables, focusTables } from './selection.js';
import { generateErDiagram } from './mermaid-definition.js';
import { clamp, fitTransform, zoomAtPoint, ZOOM_LIMITS } from './viewport.js';
import { buildQuery, parseQuery } from './url-state.js';
import { toJson, toCsv } from './table-export.js';
import { schemaToMarkdown, tableToMarkdown } from './markdown-export.js';
import { schemaToDbml } from './dbml-export.js';
import { hasDiff, changeList, tableStatuses } from './diff-view.js';
import { hasDoctor, doctorSummary, worstSeverity, tableBadges, findingGroups, columnMarkers } from './doctor-view.js';
const app = document.getElementById('truss-app');
const config = {
endpoint: app.dataset.schemaEndpoint,
connections: JSON.parse(app.dataset.connections || '[]'),
typeLabels: app.dataset.typeLabels || 'native',
warnAbove: Number(app.dataset.warnAbove || 60),
focusDepth: Number(app.dataset.focusDepth || 1),
minZoom: Number(app.dataset.minZoom || 0.4),
flagTables: app.dataset.doctorFlagTables !== '0', // passive always-on health flags on the diagram
};
// Seed the initial view from the URL query string so a shared/bookmarked link
// (e.g. ?focus=projects&filter=...) opens in that state.
const urlView = parseQuery(window.location.search);
const state = {
tables: [],
fallback: false,
generatedAt: null,
connection: (urlView.connection && config.connections.includes(urlView.connection))
? urlView.connection
: (config.connections[0] ?? null),
search: urlView.filter,
focusRoot: '',
pendingFocus: urlView.focus, // applied once the schema is loaded and validated
depth: urlView.depth != null ? Math.max(0, urlView.depth) : config.focusDepth,
laravelLabels: urlView.labels || config.typeLabels === 'laravel',
view: { zoom: 1, x: 0, y: 0 }, // translate(x,y) scale(zoom)
content: { width: 0, height: 0 }, // natural SVG size
lastKey: null, // subset signature; drives auto-fit only on content change
diff: null, // the schema diff from the API (null when disabled or no baseline)
diffMode: false, // "Changes" view: tint changed tables and show the panel
doctor: null, // the doctor report from the API (null when the panel is disabled)
doctorMode: false, // "Health" view: badge tables with findings and show the panel
};
const el = {
connection: document.getElementById('truss-connection'),
search: document.getElementById('truss-search'),
focus: document.getElementById('truss-focus'),
depth: document.getElementById('truss-depth'),
labels: document.getElementById('truss-labels'),
banners: document.getElementById('truss-banners'),
viewport: document.getElementById('truss-viewport'),
canvas: document.getElementById('truss-canvas'),
zoom: document.getElementById('truss-zoom'),
fit: document.querySelector('[data-fit]'),
zoomRange: document.getElementById('truss-zoom-range'),
zoomPct: document.getElementById('truss-zoom-pct'),
more: document.getElementById('truss-more'),
moreBtn: document.getElementById('truss-more-btn'),
legend: document.getElementById('truss-legend'),
legendBtn: document.getElementById('truss-legend-btn'),
diffBtn: document.getElementById('truss-diff-btn'),
diffPanel: document.getElementById('truss-diff-panel'),
healthBtn: document.getElementById('truss-health-btn'),
healthPanel: document.getElementById('truss-health-panel'),
healthCount: document.getElementById('truss-health-count'),
healthMaxBtn: document.getElementById('truss-health-max-btn'),
themeBtn: document.getElementById('truss-theme-btn'),
exportBtn: document.getElementById('truss-export-btn'),
statTables: document.getElementById('truss-stat-tables'),
statConn: document.getElementById('truss-stat-conn'),
statFallback: document.getElementById('truss-stat-fallback'),
statUpdated: document.getElementById('truss-stat-updated'),
popover: document.getElementById('truss-popover'),
};
const mermaid = window.mermaid;
mermaid.initialize({
startOnLoad: false,
// Neutral base; the entity/row/line colours are painted by our CSS so the
// diagram follows light/dark with no re-render.
theme: 'base',
securityLevel: 'strict',
maxTextSize: 5_000_000, // raise the guards so large schemas render
maxEdges: 10_000,
er: { useMaxWidth: false },
themeVariables: {
fontFamily: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontSize: '13px',
},
});
/* ---- selection -------------------------------------------------------- */
function currentSubset() {
let subset = filterTables(state.tables, state.search);
if (state.focusRoot) {
subset = focusTables(subset, state.focusRoot, state.depth);
}
return subset;
}
/* ---- pan / zoom ------------------------------------------------------- */
function applyTransform() {
const { x, y, zoom } = state.view;
el.canvas.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
syncZoomUi();
}
function syncZoomUi() {
if (el.zoomRange) el.zoomRange.value = String(clamp(state.view.zoom, Number(el.zoomRange.min), Number(el.zoomRange.max)));
if (el.zoomPct) el.zoomPct.textContent = `${Math.round(state.view.zoom * 100)}%`;
}
function viewportSize() {
return { width: el.viewport.clientWidth, height: el.viewport.clientHeight };
}
// `minScale` floors the zoom: the auto-fit passes config.minZoom so a large
// schema stays readable (you pan), while the Fit button passes 0 to frame the
// whole diagram at once.
function fitToViewport(minScale = 0) {
state.view = fitTransform(state.content, viewportSize(), { minScale });
applyTransform();
}
/** Size the freshly-rendered SVG to its natural pixels so our transform drives scale. */
function normalizeSvg() {
const svg = el.canvas.querySelector('svg');
if (!svg) {
state.content = { width: 0, height: 0 };
return;
}
const box = svg.viewBox?.baseVal;
const width = box?.width || svg.getBoundingClientRect().width;
const height = box?.height || svg.getBoundingClientRect().height;
svg.removeAttribute('style');
svg.setAttribute('width', String(width));
svg.setAttribute('height', String(height));
state.content = { width, height };
}
function zoomBy(factor, point) {
state.view = zoomAtPoint(state.view, point, factor);
applyTransform();
}
/** The rendered SVG node for a table by name (g.label.name holds the entity name). */
function findTableNode(name) {
const svg = el.canvas.querySelector('svg');
if (!svg || !name) return null;
for (const node of svg.querySelectorAll('g.node')) {
const label = node.querySelector('g.label.name .nodeLabel');
if (label && label.textContent.trim() === name) return node;
}
return null;
}
/** The values of an enum/set native type, or null. */
function enumValues(native) {
const m = /^\s*(?:enum|set)\s*\((.*)\)\s*$/is.exec(native);
if (!m) return null;
return m[1].split(',').map((v) => v.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
}
/** A hover tooltip for a native type: the full type, with enum/set values listed. */
function typeTooltip(native) {
const values = enumValues(native);
if (!values) return native;
return `${native.trim().slice(0, 4).toLowerCase()} (${values.length}):\n` + values.map((v) => `? ${v}`).join('\n');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
/**
* Drop a small eye button into an enum/set row's type cell (right after the
* `enum` label) to reveal its values. The type cell is used rather than the
* keys cell because the latter is often empty and too narrow ? the button would
* overflow it and become unclickable.
*/
/**
* Per-render annotations on the diagram: a hover title on every type cell (the
* full native type ? the label is compacted), and, for enum/set columns, the
* `enum` label is turned into a clickable accent that opens a popover of the
* allowed values (works on touch, unlike the hover title). The label itself is
* used as the trigger ? it is real, correctly-sized foreignObject content, so
* it stays clickable, unlike a button injected into the clipped cell.
*/
function annotateColumnTypes(tables) {
const byName = new Map(tables.map((t) => [t.name, t]));
const svg = el.canvas.querySelector('svg');
if (!svg) return;
for (const node of svg.querySelectorAll('g.node')) {
const nameLabel = node.querySelector('g.label.name .nodeLabel');
const name = nameLabel?.textContent.trim();
const table = byName.get(name);
if (!table) continue;
// The table name doubles as the trigger for the per-table export/focus menu.
nameLabel.classList.add('truss-table-name');
nameLabel.dataset.table = table.name;
nameLabel.setAttribute('role', 'button');
nameLabel.setAttribute('tabindex', '0');
nameLabel.setAttribute('title', `Export or focus ${table.name}`);
const typeCells = node.querySelectorAll('g.label.attribute-type');
table.columns.forEach((col, i) => {
const label = typeCells[i]?.querySelector('.nodeLabel');
if (!label) return;
label.setAttribute('title', typeTooltip(col.type));
const values = enumValues(col.type);
if (values) {
label.classList.add('truss-enum-type');
label.dataset.values = JSON.stringify(values);
label.setAttribute('role', 'button');
label.setAttribute('tabindex', '0');
}
});
}
}
// The single floating popover is shared: enum/set value lists anchor to a type
// label, the per-table export/focus menu anchors to a table name.
let openAnchor = null;
let menuTable = null;
// Position the shared popover next to an anchor. `placePopover` does the raw
// placement; `positionPopover` first closes the other overlays (so a toolbar or
// table menu is the only thing open). Finding markers use `placePopover` so their
// detail popover can sit alongside the open Health panel.
function placePopover(anchor) {
el.popover.hidden = false;
const r = anchor.getBoundingClientRect();
const w = el.popover.offsetWidth;
const h = el.popover.offsetHeight;
const left = Math.max(8, Math.min(r.left, window.innerWidth - w - 10));
const top = r.bottom + h + 8 > window.innerHeight ? Math.max(8, r.top - h - 6) : r.bottom + 6;
el.popover.style.left = `${left}px`;
el.popover.style.top = `${top}px`;
openAnchor = anchor;
}
function positionPopover(anchor) {
closeOverlays('popover');
placePopover(anchor);
}
// The export menu is a toolbar overlay, so it aligns to the top-right cluster
// with the Legend/Changes/Health panels instead of hanging off its button.
function positionCluster(anchor) {
closeOverlays('popover');
el.popover.hidden = false;
const host = app.getBoundingClientRect();
const w = el.popover.offsetWidth;
el.popover.style.left = `${host.right - w - 14}px`;
el.popover.style.top = `${host.top + 62}px`;
openAnchor = anchor;
}
function showEnumPopover(anchor) {
const values = JSON.parse(anchor.dataset.values || '[]');
menuTable = null;
el.popover.innerHTML = `<div class="truss-popover-head">enum · ${values.length}</div>`
+ `<ul>${values.map((v) => `<li>${escapeHtml(v)}</li>`).join('')}</ul>`;
positionPopover(anchor);
}
function showTableMenu(anchor, table) {
menuTable = table;
// When this table is already the focus, "Focus" would be a no-op, so offer the
// inverse instead of a redundant item.
const focusItem = state.focusRoot === table.name
? '<button type="button" data-act="unfocus">Unfocus this table</button>'
: '<button type="button" data-act="focus">Focus this table</button>';
el.popover.innerHTML = `<div class="truss-popover-head">${escapeHtml(table.name)}</div>`
+ '<div class="truss-menu">'
+ focusItem
+ '<button type="button" data-act="copy">Copy JSON</button>'
+ '<button type="button" data-act="json">Download JSON</button>'
+ '<button type="button" data-act="csv">Download CSV</button>'
+ '<button type="button" data-act="table-md">Download Markdown</button>'
+ '</div>';
positionPopover(anchor);
}
function hidePopover() {
if (el.popover) el.popover.hidden = true;
openAnchor = null;
menuTable = null;
el.exportBtn?.setAttribute('aria-expanded', 'false');
}
function downloadBlob(name, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function downloadFile(name, content, mime) {
downloadBlob(name, new Blob([content], { type: mime }));
}
function runMenuAction(act) {
const table = menuTable;
hidePopover();
if (act === 'png') { exportImage('png'); return; }
if (act === 'svg') { exportImage('svg'); return; }
if (act === 'md') { downloadFile(`${exportBaseName()}.md`, schemaToMarkdown(currentSubset(), { connection: state.connection }), 'text/markdown'); return; }
if (act === 'dbml') { downloadFile(`${exportBaseName()}.dbml`, schemaToDbml(currentSubset()), 'text/plain'); return; }
if (!table) return;
if (act === 'focus' || act === 'unfocus') {
state.focusRoot = act === 'focus' ? table.name : '';
if (el.focus) el.focus.value = state.focusRoot;
render();
} else if (act === 'copy') {
navigator.clipboard?.writeText(toJson(table));
} else if (act === 'json') {
downloadFile(`${table.name}.json`, toJson(table), 'application/json');
} else if (act === 'csv') {
downloadFile(`${table.name}.csv`, toCsv(table), 'text/csv');
} else if (act === 'table-md') {
downloadFile(`${table.name}.md`, tableToMarkdown(table), 'text/markdown');
}
}
/* ---- diagram export --------------------------------------------------- */
const SVG_NS = 'http://www.w3.org/2000/svg';
// Inline the shipped IBM Plex Mono weights as base64 @font-face rules, so an
// exported SVG (whose labels are re-rendered as <text>) carries its own font
// instead of depending on one being installed wherever the file is opened.
async function embedFontCss() {
const href = [...document.querySelectorAll('link[rel="stylesheet"]')]
.map((l) => l.href).find((h) => /truss\.css(\?|$)/.test(h));
if (!href) return '';
const faces = await Promise.all(['400', '600'].map(async (weight) => {
try {
const url = new URL(`ibm-plex-mono-${weight}.woff2`, href).href;
const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
let bin = '';
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
return `@font-face{font-family:'IBM Plex Mono';font-weight:${weight};font-style:normal;`
+ `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2');}`;
} catch {
return '';
}
}));
return faces.join('');
}
// Build a standalone, self-contained SVG of the current diagram. The rendered
// SVG relies on external CSS for colours and uses HTML labels in foreignObjects
// (which do not rasterise and are unsupported by most vector tools), so we
// inline the shape colours, flatten every label to a positioned <text> (mapped
// through the live CTM, so pan/zoom is irrelevant), embed the font, and paint a
// solid themed background. Returns { markup, width, height } or null.
async function buildExportSvg() {
const live = el.canvas.querySelector('svg');
if (!live) return null;
const vb = live.viewBox.baseVal;
const clone = live.cloneNode(true);
const liveShapes = live.querySelectorAll('rect, path, line, polygon');
const cloneShapes = clone.querySelectorAll('rect, path, line, polygon');
liveShapes.forEach((node, i) => {
const cs = getComputedStyle(node);
const target = cloneShapes[i];
target.setAttribute('fill', cs.fill);
if (cs.stroke !== 'none') target.setAttribute('stroke', cs.stroke);
const strokeWidth = cs.strokeWidth.replace('px', '');
if (strokeWidth) target.setAttribute('stroke-width', strokeWidth);
if (cs.strokeDasharray !== 'none') target.setAttribute('stroke-dasharray', cs.strokeDasharray);
});
// The foreignObject box is tight to its text, so a start-anchored,
// vertically-centred <text> at the box origin lines up with the layout.
const toUser = live.getScreenCTM().inverse();
const textLayer = document.createElementNS(SVG_NS, 'g');
for (const fo of live.querySelectorAll('foreignObject')) {
const text = fo.textContent.trim();
if (!text) continue;
const styled = fo.querySelector('.nodeLabel') || fo.querySelector('.edgeLabel') || fo.lastElementChild || fo;
const cs = getComputedStyle(styled);
const rect = fo.getBoundingClientRect();
const a = new DOMPoint(rect.left, rect.top).matrixTransform(toUser);
const b = new DOMPoint(rect.right, rect.bottom).matrixTransform(toUser);
const t = document.createElementNS(SVG_NS, 'text');
t.setAttribute('x', a.x.toFixed(1));
t.setAttribute('y', ((a.y + b.y) / 2).toFixed(1));
t.setAttribute('text-anchor', 'start');
t.setAttribute('dominant-baseline', 'central');
t.setAttribute('fill', cs.color);
t.setAttribute('font-size', cs.fontSize);
t.setAttribute('font-weight', cs.fontWeight);
t.setAttribute('font-family', "'IBM Plex Mono', ui-monospace, monospace");
t.textContent = text;
textLayer.appendChild(t);
}
clone.querySelectorAll('foreignObject').forEach((fo) => fo.remove());
clone.appendChild(textLayer);
const bg = document.createElementNS(SVG_NS, 'rect');
bg.setAttribute('x', vb.x);
bg.setAttribute('y', vb.y);
bg.setAttribute('width', vb.width);
bg.setAttribute('height', vb.height);
bg.setAttribute('fill', getComputedStyle(el.viewport).backgroundColor);
clone.insertBefore(bg, clone.firstChild);
const style = document.createElementNS(SVG_NS, 'style');
style.textContent = await embedFontCss();
clone.insertBefore(style, clone.firstChild);
clone.setAttribute('xmlns', SVG_NS);
return { markup: new XMLSerializer().serializeToString(clone), width: vb.width, height: vb.height };
}
function rasterize({ markup, width, height }, scale = 2) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(canvas);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('export render failed')); };
img.src = url;
});
}
function exportBaseName() {
return state.focusRoot ? `truss-${state.focusRoot}` : 'truss-schema';
}
async function exportImage(format) {
const svg = await buildExportSvg();
if (!svg) return;
if (format === 'svg') {
downloadFile(`${exportBaseName()}.svg`, svg.markup, 'image/svg+xml');
return;
}
const canvas = await rasterize(svg, 2);
canvas.toBlob((blob) => { if (blob) downloadBlob(`${exportBaseName()}.png`, blob); }, 'image/png');
}
function showExportMenu(anchor) {
menuTable = null;
el.popover.innerHTML = '<div class="truss-popover-head">Export view</div>'
+ '<div class="truss-menu">'
+ '<button type="button" data-act="png">Export PNG</button>'
+ '<button type="button" data-act="svg">Export SVG</button>'
+ '<button type="button" data-act="md">Data dictionary (Markdown)</button>'
+ '<button type="button" data-act="dbml">DBML</button>'
+ '</div>';
positionCluster(anchor);
}
/** Flag the focused table's node so CSS can highlight it (cleared otherwise). */
function markFocusedTable() {
const svg = el.canvas.querySelector('svg');
if (!svg) return;
svg.querySelectorAll('g.node.truss-focused').forEach((n) => n.classList.remove('truss-focused'));
findTableNode(state.focusRoot)?.classList.add('truss-focused');
}
/** Centre of the focused table's rendered node, in content coordinates (or null). */
function focusedTableCenter() {
const node = findTableNode(state.focusRoot);
if (!node) return null;
try {
const bb = node.getBBox();
const m = node.getCTM(); // node user space ? svg (viewBox = content) space
if (!m) return null;
const cx = bb.x + bb.width / 2;
const cy = bb.y + bb.height / 2;
return { x: m.a * cx + m.c * cy + m.e, y: m.b * cx + m.d * cy + m.f };
} catch {
return null;
}
}
/** Pan so the focused table sits at the viewport centre, keeping the fit zoom. */
function centerOnFocusedTable() {
const c = focusedTableCenter();
if (!c) return;
const vp = viewportSize();
state.view = {
...state.view,
x: vp.width / 2 - c.x * state.view.zoom,
y: vp.height / 2 - c.y * state.view.zoom,
};
applyTransform();
}
/* ---- schema diff ("Changes" view) ------------------------------------- */
// Show the Changes button only when the response carried a diff (the feature is
// on and a baseline exists). When it did not, make sure the view is off.
function updateDiffAvailability() {
const available = state.diff != null;
el.diffBtn?.toggleAttribute('hidden', !available);
if (!available && state.diffMode) closeDiff();
}
function toggleDiff() {
if (state.diff == null) return;
state.diffMode ? closeDiff() : openDiff();
}
function openDiff() {
closeOverlays('diff');
state.diffMode = true;
el.diffBtn?.setAttribute('aria-expanded', 'true');
renderDiffPanel();
el.diffPanel?.removeAttribute('hidden');
markDiffTables();
}
function closeDiff() {
state.diffMode = false;
el.diffBtn?.setAttribute('aria-expanded', 'false');
el.diffPanel?.setAttribute('hidden', '');
clearDiffTables();
}
// Tint the added and changed table nodes. Removed tables are not rendered (they
// are gone from the current schema), so the panel lists them instead.
function clearDiffTables() {
el.canvas.querySelector('svg')
?.querySelectorAll('g.node.truss-diff-added, g.node.truss-diff-changed')
.forEach((n) => n.classList.remove('truss-diff-added', 'truss-diff-changed'));
}
function markDiffTables() {
clearDiffTables();
if (!state.diffMode || !hasDiff(state.diff)) return;
for (const [name, status] of tableStatuses(state.diff)) {
if (status === 'removed') continue;
findTableNode(name)?.classList.add(`truss-diff-${status}`);
}
}
function renderDiffPanel() {
const body = el.diffPanel?.querySelector('.truss-diff-body');
if (!body) return;
if (!hasDiff(state.diff)) {
body.innerHTML = '<p class="truss-diff-empty">No structural changes since the last migration.</p>';
return;
}
body.innerHTML = changeList(state.diff).map((item) => {
// Added and changed tables still exist in the diagram, so their names are
// focus links; removed tables are gone, so they stay plain text.
if (item.kind === 'table-added') return diffRow('added', item.table, '', true);
if (item.kind === 'table-removed') return diffRow('removed', item.table, '', false);
const details = item.details.map((d) => `<li>${escapeHtml(describeDetail(d))}</li>`).join('');
return diffRow('changed', item.table, details, true);
}).join('');
}
function diffRow(status, table, details = '', focusable = false) {
const label = focusable
? `<button type="button" class="truss-diff-focus" data-diff-focus="${escapeHtml(table)}">${escapeHtml(table)}</button>`
: escapeHtml(table);
const list = details ? `<ul class="truss-diff-details">${details}</ul>` : '';
return `<div class="truss-diff-item truss-diff-item--${status}">`
+ `<span class="truss-diff-badge">${status}</span> ${label}${list}</div>`;
}
// Focus a table straight from the Changes panel, reusing the focus pipeline.
function focusTableFromDiff(name) {
if (!state.tables.some((t) => t.name === name)) return;
state.focusRoot = name;
if (el.focus) el.focus.value = name;
render();
}
function describeDetail(d) {
switch (d.kind) {
case 'column-added': return `+ column ${d.name} (${d.type})`;
case 'column-removed': return `- column ${d.name}`;
case 'column-changed': return `~ column ${d.name} (${Object.entries(d.changes)
.map(([field, c]) => `${field}: ${diffValue(c.before)} ? ${diffValue(c.after)}`).join(', ')})`;
case 'index-added': return `+ index ${d.name}`;
case 'index-removed': return `- index ${d.name}`;
case 'index-changed': return `~ index ${d.name}`;
case 'fk-added': return `+ foreign key ${d.name}`;
case 'fk-removed': return `- foreign key ${d.name}`;
case 'fk-changed': return `~ foreign key ${d.name}`;
case 'primary-key-changed': return `~ primary key [${d.before.join(', ')}] ? [${d.after.join(', ')}]`;
default: return '';
}
}
function diffValue(value) {
if (value === null) return 'null';
if (value === true) return 'true';
if (value === false) return 'false';
return Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
}
/* ---- structure health ("Health" view) --------------------------------- */
// Show the Health button only when the response carried a doctor payload (the
// panel is enabled). The count bubble reflects the total, coloured by the worst
// severity present; when there are no findings the button reads as a clean pass.
function updateDoctorAvailability() {
const available = state.doctor != null;
el.healthBtn?.toggleAttribute('hidden', !available);
if (!available) {
if (state.doctorMode) closeHealth();
return;
}
const { total } = doctorSummary(state.doctor);
const worst = worstSeverity(state.doctor) ?? 'clean';
el.healthBtn?.setAttribute('data-severity', worst);
el.healthBtn?.setAttribute('title', total > 0
? `Structure health: ${total} finding${total === 1 ? '' : 's'} (truss:doctor)`
: 'Structure health: no problems found (truss:doctor)');
if (el.healthCount) {
el.healthCount.textContent = total > 0 ? String(total) : '';
el.healthCount.toggleAttribute('hidden', total === 0);
}
}
function toggleHealth() {
if (state.doctor == null) return;
if (state.doctorMode) {
closeHealth();
hidePopover(); // also dismiss a finding popover opened from a marker
} else {
openHealth();
}
}
function openHealth() {
closeOverlays('health');
state.doctorMode = true;
el.healthBtn?.setAttribute('aria-expanded', 'true');
renderHealthPanel();
el.healthPanel?.removeAttribute('hidden');
markDoctorBadges();
markDoctorRows(currentSubset());
}
function closeHealth() {
state.doctorMode = false;
el.healthBtn?.setAttribute('aria-expanded', 'false');
el.healthPanel?.setAttribute('hidden', '');
restoreHealth();
clearDoctorBadges();
clearDoctorRows();
}
// Expand the panel to a large centred overlay for reading, or restore it.
function toggleHealthMax() {
el.healthPanel?.classList.contains('is-maximized') ? restoreHealth() : maximizeHealth();
}
function maximizeHealth() {
el.healthPanel?.classList.add('is-maximized');
el.healthMaxBtn?.setAttribute('aria-pressed', 'true');
if (el.healthMaxBtn) {
el.healthMaxBtn.textContent = '?';
el.healthMaxBtn.title = 'Restore';
}
}
function restoreHealth() {
el.healthPanel?.classList.remove('is-maximized');
el.healthMaxBtn?.setAttribute('aria-pressed', 'false');
if (el.healthMaxBtn) {
el.healthMaxBtn.textContent = '?';
el.healthMaxBtn.title = 'Maximize';
}
}
// Badge the diagram nodes for tables that have findings, coloured by their worst
// severity. Only active while the Health panel is open, so the canvas stays clean
// otherwise.
function clearDoctorBadges() {
el.canvas.querySelector('svg')
?.querySelectorAll('g.node.truss-health-error, g.node.truss-health-warning, g.node.truss-health-info')
.forEach((n) => n.classList.remove('truss-health-error', 'truss-health-warning', 'truss-health-info'));
}
function markDoctorBadges() {
clearDoctorBadges();
if (!state.doctorMode || !hasDoctor(state.doctor)) return;
for (const [name, { severity }] of tableBadges(state.doctor)) {
findTableNode(name)?.classList.add(`truss-health-${severity}`);
}
}
// A passive, always-on flag: a small severity-coloured badge with the finding
// count on the corner of every table that has findings, independent of the Health
// panel, so a problem is visible just by looking at the table. Gated by the
// `flag_tables` config. Drawn as an SVG element inside the node, so it zooms and
// pans with the diagram and sits on top of any Changes tint without conflicting.
const SVGNS = 'http://www.w3.org/2000/svg';
function clearPassiveHealth() {
el.canvas.querySelector('svg')?.querySelectorAll('.truss-health-flag').forEach((n) => n.remove());
}
function markPassiveHealth(subset) {
clearPassiveHealth();
const svg = el.canvas.querySelector('svg');
if (!svg || !config.flagTables || !hasDoctor(state.doctor)) return;
const present = new Set(subset.map((t) => t.name));
for (const [name, { severity, count }] of tableBadges(state.doctor)) {
if (!present.has(name)) continue;
const node = findTableNode(name);
if (!node) continue;
let bb;
try { bb = node.getBBox(); } catch { continue; }
const flag = document.createElementNS(SVGNS, 'g');
flag.setAttribute('class', `truss-health-flag truss-health-flag--${severity}`);
flag.setAttribute('transform', `translate(${bb.x + bb.width - 3}, ${bb.y + 3})`);
const circle = document.createElementNS(SVGNS, 'circle');
circle.setAttribute('r', '9');
const text = document.createElementNS(SVGNS, 'text');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.textContent = String(count);
flag.append(circle, text);
node.append(flag);
}
}
// Mark the diagram rows whose column has a finding, so the problem is visible on
// the table itself. Reuses the per-column type cells (indexed like
// annotateColumnTypes); each marker opens the finding popover on click.
function clearDoctorRows() {
el.canvas.querySelector('svg')?.querySelectorAll('.truss-health-marker').forEach((label) => {
label.classList.remove('truss-health-marker', 'truss-health-marker--error', 'truss-health-marker--warning', 'truss-health-marker--info');
delete label.dataset.healthTable;
delete label.dataset.healthColumn;
});
}
function markDoctorRows(subset) {
clearDoctorRows();
const svg = el.canvas.querySelector('svg');
if (!svg || !state.doctorMode || !hasDoctor(state.doctor)) return;
const byName = new Map(subset.map((t) => [t.name, t]));
for (const node of svg.querySelectorAll('g.node')) {
const nameLabel = node.querySelector('g.label.name .nodeLabel');
const table = byName.get(nameLabel?.textContent.trim());
if (!table) continue;
const markers = columnMarkers(state.doctor, table.name, table.columns.map((c) => c.name));
if (markers.size === 0) continue;
// Mark the column *name* cell (indexed like the type cells), so the affordance
// sits on the field the finding is about.
const nameCells = node.querySelectorAll('g.label.attribute-name');
table.columns.forEach((col, i) => {
const marker = markers.get(col.name);
const label = nameCells[i]?.querySelector('.nodeLabel');
if (!marker || !label) return;
label.classList.add('truss-health-marker', `truss-health-marker--${marker.severity}`);
label.dataset.healthTable = table.name;
label.dataset.healthColumn = col.name;
label.setAttribute('role', 'button');
label.setAttribute('tabindex', '0');
label.setAttribute('title', marker.findings.map((f) => f.message).join('\n'));
});
}
}
// The detail popover for a marked column: its findings, each with message and
// hint. Uses placePopover so it can sit alongside the open Health panel.
function showFindingPopover(anchor, tableName, column) {
const findings = (state.doctor?.findings ?? []).filter((f) => f.table === tableName && f.column === column);
if (findings.length === 0) return;
menuTable = null;
el.popover.innerHTML = `<div class="truss-popover-head">${escapeHtml(tableName)}.${escapeHtml(column)}</div>`
+ '<ul class="truss-health-pop">'
+ findings.map((f) => `<li class="truss-health-pop-item truss-health-item--${f.severity}">`
+ `<div class="truss-health-item-head"><span class="truss-health-badge">${escapeHtml(f.severity)}</span> `
+ `<code class="truss-health-code">${escapeHtml(f.code)}</code></div>`
+ `<div class="truss-health-msg">${escapeHtml(f.message)}</div>`
+ (f.hint ? `<div class="truss-health-hint">${escapeHtml(f.hint)}</div>` : '')
+ '</li>').join('')
+ '</ul>';
placePopover(anchor);
}
function renderHealthPanel() {
const body = el.healthPanel?.querySelector('.truss-health-body');
if (!body) return;
const groups = findingGroups(state.doctor);
if (groups.length === 0) {
body.innerHTML = '<p class="truss-health-empty">No structural problems found.</p>';
return;
}
// On a large schema the groups start collapsed so the panel stays scannable.
const open = state.tables.length <= config.warnAbove;
body.innerHTML = groups.map((group) => healthGroup(group, open)).join('');
}
function healthGroup(group, open) {
const items = group.findings.map((f) => healthItem(f)).join('');
return `<details class="truss-health-group truss-health-group--${group.severity}"${open ? ' open' : ''}>`
+ '<summary class="truss-health-group-head">'
+ `<span class="truss-health-dot truss-health-dot--${group.severity}"></span>`
+ `<span class="truss-health-table">${escapeHtml(group.table)}</span>`
+ `<span class="truss-health-count-inline">${group.findings.length}</span></summary>`
+ '<div class="truss-health-group-body">'
+ `<button type="button" class="truss-health-focus" data-health-focus="${escapeHtml(group.table)}">Focus this table</button>`
+ `<ul class="truss-health-items">${items}</ul></div></details>`;
}
function healthItem(finding) {
const location = finding.column ?? '(table)';
const heuristic = finding.confidence === 'heuristic'
? ' <span class="truss-health-heuristic" title="Heuristic: a lower-confidence signal">heuristic</span>'
: '';
return `<li class="truss-health-item truss-health-item--${finding.severity}">`
+ `<div class="truss-health-item-head"><span class="truss-health-badge">${escapeHtml(finding.severity)}</span> `
+ `<code class="truss-health-code">${escapeHtml(finding.code)}</code> `
+ `<span class="truss-health-loc">${escapeHtml(location)}</span>${heuristic}</div>`
+ `<div class="truss-health-msg">${escapeHtml(finding.message)}</div></li>`;
}
// Focus a table straight from the Health panel, reusing the focus pipeline.
function focusTableFromDoctor(name) {
if (!state.tables.some((t) => t.name === name)) return;
state.focusRoot = name;
if (el.focus) el.focus.value = name;
render();
}
/* ---- overlay coordination --------------------------------------------- */
// Only one overlay is open at a time: opening any panel or menu closes the rest,
// so they never stack or overlap. `except` names the one being opened, so it is
// left untouched. The toolbar overlays (Legend, Changes, Health, the Export menu)
// all anchor to the same top-right cluster.
function closeOverlays(except) {
if (except !== 'legend' && el.legend && !el.legend.hasAttribute('hidden')) closeLegend();
if (except !== 'diff' && state.diffMode) closeDiff();
if (except !== 'health' && state.doctorMode) closeHealth();
if (except !== 'more' && el.more?.classList.contains('is-open')) closeMore();
if (except !== 'popover') hidePopover();
}
function openLegend() {
closeOverlays('legend');
el.legend?.removeAttribute('hidden');
el.legendBtn?.setAttribute('aria-expanded', 'true');
}
function closeLegend() {
el.legend?.setAttribute('hidden', '');
el.legendBtn?.setAttribute('aria-expanded', 'false');
}
function toggleLegend() {
el.legend?.hasAttribute('hidden') ? openLegend() : closeLegend();
}
function openMore() {
closeOverlays('more');
el.more?.classList.add('is-open');
el.moreBtn?.setAttribute('aria-expanded', 'true');
}
function closeMore() {
el.more?.classList.remove('is-open');
el.moreBtn?.setAttribute('aria-expanded', 'false');
}
function toggleMore() {
el.more?.classList.contains('is-open') ? closeMore() : openMore();
}
/* ---- rendering -------------------------------------------------------- */
function banner(kind, text) {
const node = document.createElement('div');
node.className = `truss-banner truss-banner--${kind}`;
node.textContent = text;
return node;
}
function renderBanners(subsetCount) {
el.banners.replaceChildren();
if (state.fallback) {
el.banners.append(banner('warn',
'A database connection was not available; the schema was rebuilt from an in-memory SQLite replay, so column types may be approximate.'));
}
const unscoped = !state.search && !state.focusRoot;
if (unscoped && state.tables.length > config.warnAbove) {
el.banners.append(banner('info',
`${state.tables.length} tables ? large schema. Use the filter or focus a table to keep the diagram fast and legible.`));
}
if (subsetCount === 0) {
el.banners.append(banner('info', 'No tables match the current filter or focus.'));
}
}
/** Reflect the current view (filter, focus, depth, type labels, connection) in the URL. */
function syncUrl() {
const qs = buildQuery({
connection: config.connections.length > 1 ? state.connection : null,
filter: state.search,
focus: state.focusRoot,
depth: (state.focusRoot && state.depth !== config.focusDepth) ? state.depth : null,
labels: state.laravelLabels,
});
window.history.replaceState(null, '', qs || window.location.pathname);
}
async function render() {
const subset = currentSubset();
syncUrl();
renderBanners(subset.length);
if (subset.length === 0) {
el.canvas.replaceChildren();
return;
}
const definition = generateErDiagram(subset, {
typeLabels: state.laravelLabels ? 'laravel' : 'native',
});
const key = subset.map((t) => t.name).join('|');
try {
const { svg } = await mermaid.render('truss-graph', definition);
hidePopover(); // the eye it anchored to is about to be replaced
el.canvas.innerHTML = svg;
normalizeSvg();
markFocusedTable();
markDiffTables(); // re-tint after a re-render when the Changes view is on
markDoctorBadges(); // re-badge after a re-render when the Health view is on
markPassiveHealth(subset); // always-on flags on tables with findings (config-gated)
annotateColumnTypes(subset);
markDoctorRows(subset); // after annotateColumnTypes, so a marked row's title wins
// Auto-fit only when the *content* changed (new tables): filtering and
// focusing frame their result (honouring the readable floor), while a label
// toggle keeps your view. When focusing, re-centre on the focused table so
// it lands in the middle rather than wherever the layout put it.
if (key !== state.lastKey) {
fitToViewport(config.minZoom);
if (state.focusRoot) centerOnFocusedTable();
} else {
applyTransform();
}
state.lastKey = key;
} catch (error) {
el.canvas.replaceChildren(banner('error', `Diagram failed to render: ${error?.message ?? error}`));
}
}
/* ---- status footer ---------------------------------------------------- */
function timeAgo(iso) {
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
}
function updateFooter() {
const n = state.tables.length;
if (el.statTables) el.statTables.textContent = `${n} ${n === 1 ? 'table' : 'tables'}`;
if (el.statConn) el.statConn.textContent = state.connection ?? '';
if (el.statFallback) el.statFallback.toggleAttribute('hidden', !state.fallback);
if (el.statUpdated) el.statUpdated.textContent = state.generatedAt ? `updated ${timeAgo(state.generatedAt)}` : '';
}
/* ---- data ------------------------------------------------------------- */
function populateFocusOptions() {
el.focus.innerHTML = ['<option value="">? none ?</option>']
.concat(state.tables.map((t) => `<option value="${t.name}">${t.name}</option>`))
.join('');
el.focus.value = state.focusRoot;
}
function populateConnectionOptions() {
if (!el.connection) return;
el.connection.innerHTML = config.connections.map((name) => `<option value="${name}">${name}</option>`).join('');
if (state.connection) el.connection.value = state.connection;
el.connection.closest('.truss-field')?.toggleAttribute('hidden', config.connections.length < 2);
}
async function loadSchema() {
const url = new URL(config.endpoint, window.location.origin);
if (state.connection) url.searchParams.set('connection', state.connection);
el.banners.replaceChildren(banner('info', 'Loading schema?'));
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) {
el.banners.replaceChildren(banner('error', `Could not load schema (HTTP ${response.status}).`));
return;
}
const payload = await response.json();
state.tables = payload.tables ?? [];
state.fallback = Boolean(payload.fallback);
state.generatedAt = payload.generated_at ?? null;
state.diff = payload.diff ?? null;
state.doctor = payload.doctor ?? null;
// Apply a focus requested via the URL, once we can confirm the table exists.
state.focusRoot = (state.pendingFocus && state.tables.some((t) => t.name === state.pendingFocus))
? state.pendingFocus
: '';
state.pendingFocus = null;
state.lastKey = null; // force an auto-fit for the new schema
populateFocusOptions();
updateFooter();
updateDiffAvailability();
updateDoctorAvailability();
await render();
}
/* ---- theme ------------------------------------------------------------ */
// auto (follow OS) ? light ? dark ? auto. Persisted; drives the CSS via
// data-theme (absent = auto/prefers-color-scheme).
function applyTheme() {
const t = localStorage.getItem('truss-theme');
if (t === 'light' || t === 'dark') app.ownerDocument.documentElement.setAttribute('data-theme', t);
else app.ownerDocument.documentElement.removeAttribute('data-theme');
if (el.themeBtn) {
el.themeBtn.textContent = t === 'light' ? '?' : t === 'dark' ? '?' : '?';
el.themeBtn.title = `Theme: ${t || 'auto'}`;
}
}
function cycleTheme() {
const cur = localStorage.getItem('truss-theme');
const next = cur == null ? 'light' : cur === 'light' ? 'dark' : null;
if (next) localStorage.setItem('truss-theme', next);
else localStorage.removeItem('truss-theme');
applyTheme();
}
/* ---- events ----------------------------------------------------------- */
function debounce(fn, ms) {
let handle;
return (...args) => {
clearTimeout(handle);
handle = setTimeout(() => fn(...args), ms);
};
}
function wireEvents() {
el.connection?.addEventListener('change', (e) => {
state.connection = e.target.value;
loadSchema();
});
el.search?.addEventListener('input', debounce((e) => {
state.search = e.target.value;
render();
}, 150));
el.focus?.addEventListener('change', (e) => {
state.focusRoot = e.target.value;
render();
});
el.depth?.addEventListener('change', (e) => {
state.depth = Math.max(0, Number(e.target.value) || 0);
render();
});
el.labels?.addEventListener('change', (e) => {
state.laravelLabels = e.target.checked;
render();
});
// Wheel zooms toward the cursor (never scrolls the page).
el.viewport.addEventListener('wheel', (e) => {
e.preventDefault();
hidePopover();
const rect = el.viewport.getBoundingClientRect();
zoomBy(e.deltaY < 0 ? 1.1 : 1 / 1.1, { x: e.clientX - rect.left, y: e.clientY - rect.top });
}, { passive: false });
// Enum label and table name: open/close the shared popover. Delegated so it
// survives re-renders. A menu action click is handled by its own listener below.
el.canvas.addEventListener('click', (e) => {
const marker = e.target.closest('.truss-health-marker');
if (marker) {
e.stopPropagation();
if (openAnchor === marker) hidePopover();
else showFindingPopover(marker, marker.dataset.healthTable, marker.dataset.healthColumn);
return;
}
const enumLabel = e.target.closest('.truss-enum-type');
if (enumLabel) {
e.stopPropagation();
if (openAnchor === enumLabel) hidePopover();
else showEnumPopover(enumLabel);
return;
}
const nameLabel = e.target.closest('.truss-table-name');
if (nameLabel) {
e.stopPropagation();
const table = state.tables.find((t) => t.name === nameLabel.dataset.table);
if (openAnchor === nameLabel || !table) hidePopover();
else showTableMenu(nameLabel, table);
}
});
el.popover.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-act]');
if (btn) runMenuAction(btn.dataset.act);
});
el.exportBtn?.addEventListener('click', (e) => {
e.stopPropagation();
if (openAnchor === el.exportBtn) {
hidePopover();
} else {
showExportMenu(el.exportBtn);
el.exportBtn.setAttribute('aria-expanded', 'true');
}
});
document.addEventListener('click', (e) => {
if (openAnchor && !e.target.closest('.truss-popover, .truss-enum-type, .truss-table-name, .truss-health-marker, #truss-export-btn')) hidePopover();
});
// Unified pointer handling: one pointer pans (mouse or touch), two pointers
// pinch-zoom around their midpoint. Overlay controls are excluded.
const pointers = new Map();
let lastPinch = 0;
const dist = () => { const p = [...pointers.values()]; return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); };
const mid = (rect) => { const p = [...pointers.values()]; return { x: (p[0].x + p[1].x) / 2 - rect.left, y: (p[0].y + p[1].y) / 2 - rect.top }; };
el.viewport.addEventListener('pointerdown', (e) => {
if (e.target.closest?.('#truss-zoom, .truss-legend, .truss-enum-type, .truss-table-name, .truss-health-marker, .truss-popover')) return;
hidePopover();
e.preventDefault();
el.viewport.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) el.viewport.classList.add('is-panning');
if (pointers.size === 2) lastPinch = dist();
});
el.viewport.addEventListener('pointermove', (e) => {
const p = pointers.get(e.pointerId);
if (!p) return;
const prev = { x: p.x, y: p.y };
p.x = e.clientX; p.y = e.clientY;
if (pointers.size >= 2) {
const d = dist();
if (lastPinch) zoomBy(d / lastPinch, mid(el.viewport.getBoundingClientRect()));
lastPinch = d;
} else {
state.view = { ...state.view, x: state.view.x + (e.clientX - prev.x), y: state.view.y + (e.clientY - prev.y) };
applyTransform();
}
});
const endPointer = (e) => {
pointers.delete(e.pointerId);
if (pointers.size < 2) lastPinch = 0;
if (pointers.size === 0) el.viewport.classList.remove('is-panning');
};
el.viewport.addEventListener('pointerup', endPointer);
el.viewport.addEventListener('pointercancel', endPointer);
// Slider zooms around the viewport centre.
el.zoomRange?.addEventListener('input', (e) => {
const target = clamp(Number(e.target.value), ZOOM_LIMITS.min, ZOOM_LIMITS.max);
const { width, height } = viewportSize();
zoomBy(target / state.view.zoom, { x: width / 2, y: height / 2 });
});
// The Fit button frames the whole diagram (ignores the readable floor).
el.fit?.addEventListener('click', () => fitToViewport(0));
// Utility cluster.
el.moreBtn?.addEventListener('click', toggleMore);
el.legendBtn?.addEventListener('click', toggleLegend);
el.diffBtn?.addEventListener('click', toggleDiff);
el.diffPanel?.addEventListener('click', (e) => {
const focus = e.target.closest('[data-diff-focus]');
if (focus) focusTableFromDiff(focus.dataset.diffFocus);
});
el.healthBtn?.addEventListener('click', toggleHealth);
el.healthMaxBtn?.addEventListener('click', toggleHealthMax);
el.healthPanel?.addEventListener('click', (e) => {
const focus = e.target.closest('[data-health-focus]');
if (focus) focusTableFromDoctor(focus.dataset.healthFocus);
});
el.themeBtn?.addEventListener('click', cycleTheme);
window.addEventListener('resize', debounce(() => applyTransform(), 200));
}
/* ---- boot ------------------------------------------------------------- */
if (el.search) el.search.value = state.search;
if (el.depth) el.depth.value = String(state.depth);
if (el.labels) el.labels.checked = state.laravelLabels;
applyTheme();
populateConnectionOptions();
updateFooter();
wireEvents();
loadSchema();
|