Files
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

1120 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Schema view (Phase 2 redesign): explicit-position ontology diagram.
//
// Replaces the previous "type-cards above a force-graph hairball" layout
// with a single primary diagram that's structured, not floating:
// - Node-type boxes positioned in canonical pipeline order
// (Documents → Chunks → Entities → Types → Summaries → Context → Schema)
// - Each box surfaces type name, instance count, modal pipeline+task,
// and a short field inventory
// - Edges between node types drawn as labelled curves carrying the
// relation name + edge count
// - Below the diagram: edge-type cards listing every relationship type
// with its source→target pairs
// - Click a node box or edge card to highlight related elements
//
// Reads ``schemaGraphData`` produced by preprocessor.extract_type_schema_graph_data:
// - nodes of type GraphNodeType (one per node type, with source_pipeline/
// source_task/instance_count surfaced at the top level)
// - nodes of type GraphRelationshipType (one per source_type → target_type
// pair, with source_type/target_type/relationship_label/edge_count)
// - links connect type→rel ("from") and rel→type ("to")
(function(){
const schemaData = __SCHEMA_DATA__;
const schemaGraphData = __SCHEMA_GRAPH_DATA__;
const emptyEl = document.getElementById('schema-empty');
const svgEl = document.getElementById('schema-svg');
function escapeHtml(s){
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function stripCoverage(s){
return String(s || '').replace(/\s*\d+%\s*$/, '');
}
function truncate(s, n){
const text = String(s == null ? '' : s);
return text.length > n ? text.slice(0, n) + '…' : text;
}
// Stage column position by node-type rank (matches preprocessor.node_type_rank).
// Used to lay out type boxes in pipeline order.
// Column titles keyed by rank. Negative ranks are the actor / ownership
// layer that flows in before the document→memory pipeline.
const STAGE_LABELS = {
'-5': 'Organization',
'-4': 'People',
'-3': 'Agents',
'-2': 'Sessions',
'-1': 'Brain',
'0': 'Documents',
'1': 'Chunks',
'2': 'Entities',
'3': 'Types',
'4': 'Summaries',
'5': 'Context',
};
function stageLabel(rank){
if (typeof rank !== 'number' || !isFinite(rank)) return '';
return STAGE_LABELS[Math.round(rank)] || '';
}
// Per-type accent color — a calm, restrained Segment-style palette
// (green-forward with supporting blue / violet / slate) for the airy
// light schema surface.
const ACCENT_BY_TYPE = {
// Actor / ownership layer
Tenant: '#0E7C66',
User: '#2F80ED',
Agent: '#F2994A',
Dataset: '#7C5CFC',
Session: '#EB5757',
// Document → memory pipeline
TextDocument: '#64748B',
DocumentChunk: '#1F9E6E',
TextSummary: '#B794F6',
GlobalContextSummary: '#2D9CDB',
Entity: '#1F9E6E',
EntityType: '#B794F6',
DatabaseSchema: '#2D9CDB',
SchemaTable: '#7C5CFC',
SchemaRelationship: '#8792A2',
TableType: '#7C5CFC',
TableRow: '#7C5CFC',
ColumnValue: '#8792A2',
};
function accentFor(name){ return ACCENT_BY_TYPE[name] || '#1F9E6E'; }
// ── Build the schema model from preprocessor data ────────────────
function buildSchemaModel() {
if (!schemaGraphData || !Array.isArray(schemaGraphData.nodes) || !schemaGraphData.nodes.length) {
return { nodeTypes: [], edgeTypes: [] };
}
const nodeTypes = [];
const edgeTypes = [];
window._schemaTypeIndex = {};
// Instance-level drill-down data (PR: instance-aware inspector).
window._schemaInstancesByType = (schemaGraphData && schemaGraphData.instances_by_type) || {};
window._schemaInstanceIndex = (schemaGraphData && schemaGraphData.instance_index) || {};
// Transformation impact-layer (operations → schema types).
window._schemaOperations = (schemaGraphData && schemaGraphData.operations) || [];
window._schemaOperationLinks = (schemaGraphData && schemaGraphData.operation_links) || [];
schemaGraphData.nodes.forEach(function(n) {
if (n.type === 'GraphNodeType') {
var t = {
id: String(n.id),
name: String(n.name || n.id),
rank: Number.isFinite(Number(n.rank)) ? Number(n.rank) : 4,
fields: Array.isArray(n.fields) ? n.fields : [],
instance_count: n.instance_count != null ? n.instance_count : null,
source_pipeline: n.source_pipeline || null,
source_task: n.source_task || null,
source_user: n.source_user || null,
// PR2 contract: bounded instance display-names + sample size.
samples: Array.isArray(n.samples) ? n.samples : [],
sample_size: n.sample_size != null ? n.sample_size : (Array.isArray(n.samples) ? n.samples.length : 0),
// PR2 contract: full outgoing relationship distribution per type.
relationships: Array.isArray(n.relationships) ? n.relationships : [],
};
nodeTypes.push(t);
// Index the full datum by name so the inspector click handler can
// read samples / relationships without re-walking schemaGraphData.
window._schemaTypeIndex[t.name] = t;
} else if (n.type === 'GraphRelationshipType') {
edgeTypes.push({
id: String(n.id),
source_type: n.source_type || '',
target_type: n.target_type || '',
// relationship_label looks like "is_a (4)" or "contains (4), made_from (2) +1 more"
relationship_label: n.relationship_label || '',
edge_count: n.edge_count || 0,
});
}
});
// Sort node types by rank, then name; gives stable Story order
nodeTypes.sort(function(a, b) {
if (a.rank !== b.rank) return a.rank - b.rank;
return a.name.localeCompare(b.name);
});
return { nodeTypes: nodeTypes, edgeTypes: edgeTypes };
}
// Extract the primary relation name from a relationship_label string.
// "contains (4), made_from (2) +1 more" → "contains"
function primaryRelation(rel){
const m = /^([^\s(,]+)/.exec(rel.relationship_label || '');
return m ? m[1] : 'related';
}
// ── Layout: place node-type boxes in stage columns ───────────────
// Returns a map of type-name → {x, y, w, h, type} and metadata for the
// overall diagram bounds.
// Layout sizing — exported so the renderer draws the same header/card metrics.
const LAYOUT = {
PAD_LEFT: 56, PAD_TOP: 64, BOX_W: 212, COL_GAP: 128, ROW_GAP: 44,
HEADER_H: 42, CARD_H: 27, MORE_H: 20, BOTTOM_PAD: 12, MAX_CARDS: 5, LENS_H: 66,
};
function instancesForType(name) {
return (window._schemaInstancesByType || {})[name] || [];
}
function isTypeExpanded(name) {
return !!(window._schemaExpanded || {})[name];
}
// A type box grows to fit its instance mini-cards (capped at MAX_CARDS unless
// expanded); a lensed single-instance box stays compact.
function boxHeight(t) {
if (t.instance_count == null) return LAYOUT.LENS_H;
const insts = instancesForType(t.name);
const total = t.instance_count || insts.length || 0;
if (!total) return LAYOUT.LENS_H;
const shown = isTypeExpanded(t.name)
? Math.min(insts.length, total)
: Math.min(total, LAYOUT.MAX_CARDS);
const more = total > LAYOUT.MAX_CARDS ? LAYOUT.MORE_H : 0; // room for the expand/collapse toggle
return LAYOUT.HEADER_H + shown * LAYOUT.CARD_H + more + LAYOUT.BOTTOM_PAD;
}
function layoutBoxes(nodeTypes, totalWidth) {
const { PAD_LEFT, PAD_TOP, BOX_W, COL_GAP, ROW_GAP } = LAYOUT;
const byRank = {};
nodeTypes.forEach(function(t) {
const r = Math.round(t.rank);
if (!byRank[r]) byRank[r] = [];
byRank[r].push(t);
});
const ranks = Object.keys(byRank).map(Number).sort(function(a, b) { return a - b; });
const positions = {};
let maxY = 0;
ranks.forEach(function(r, colIdx) {
const colX = PAD_LEFT + colIdx * (BOX_W + COL_GAP);
let y = PAD_TOP;
byRank[r].forEach(function(t) {
const h = boxHeight(t);
positions[t.name] = { x: colX, y: y, w: BOX_W, h: h, type: t, rank: r };
if (y + h > maxY) maxY = y + h;
y += h + ROW_GAP;
});
});
const diagramW = Math.max(totalWidth, PAD_LEFT + ranks.length * (BOX_W + COL_GAP));
const diagramH = maxY + 32;
return {
positions: positions,
width: diagramW,
height: diagramH,
ranks: ranks,
byRank: byRank,
box_w: BOX_W,
pad_left: PAD_LEFT,
col_gap: COL_GAP,
pad_top: PAD_TOP,
};
}
// Pick a connection point on the box perimeter aimed at a target point.
// Keeps arrows from emerging out of the middle of a box.
function boxAnchor(box, towardX, towardY) {
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
const dx = towardX - cx;
const dy = towardY - cy;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return { x: cx, y: cy };
const halfW = box.w / 2;
const halfH = box.h / 2;
const slope = Math.abs(dy / (dx || 0.001));
if (slope * halfW < halfH) {
// Exits left/right side
const sign = dx > 0 ? 1 : -1;
return { x: cx + sign * halfW, y: cy + sign * halfW * (dy / (dx || 0.001)) };
}
const sign = dy > 0 ? 1 : -1;
return { x: cx + sign * halfH * (dx / (dy || 0.001)), y: cy + sign * halfH };
}
// ── Render the SVG diagram ───────────────────────────────────────
function renderSchemaDiagram(model, savedTransform) {
const svg = d3.select('#schema-svg');
svg.selectAll('*').remove();
// Follow the global theme toggle (ui_chrome re-renders the schema on
// toggle). The light palette is the Segment-style airy surface; the dark
// palette mirrors it on deep neutrals.
const isLight = window._isLightMode !== false;
const P = isLight
? {
card: '#ffffff', cardBorder: '#E6E8EB',
title: '#1A1F36', muted: '#8792A2',
pill: '#EEF1F4', pillText: '#4A5568',
mini: '#F6F8FA', miniBorder: '#EDF0F3', miniText: '#1A1F36',
shadow: '#1A1F36', shadowOpacity: 0.10,
}
: {
card: '#23272F', cardBorder: '#3A4049',
title: '#ECEFF4', muted: '#9AA4B2',
pill: '#2E333C', pillText: '#C3CAD4',
mini: '#2A2F37', miniBorder: '#3A4049', miniText: '#E4E8EE',
shadow: '#000000', shadowOpacity: 0.40,
};
// Absolute rects captured during render so the spotlight overlay can draw
// instance-level connectors without re-laying-out the diagram.
window._miniCardRects = {};
window._boxRectByType = {};
const modifiedTypes = modifiedTypeNames();
if (!model.nodeTypes.length) {
return;
}
const section = document.getElementById('schema-diagram-section');
const containerW = (section && section.clientWidth) || 1200;
const layout = layoutBoxes(model.nodeTypes, containerW);
// The SVG fills the viewport; the diagram lives in a pan/zoomable root <g>
// (so the content can extend past the viewport and under the side panel).
svg.attr('width', (section && section.clientWidth) || containerW)
.attr('height', (section && section.clientHeight) || 800);
const root = svg.append('g');
// Accent is keyed by TYPE. In the type view a box's name IS its type; in the
// lensed instance view a box carries an explicit accentType (its type).
const accentTypeByName = {};
model.nodeTypes.forEach(function(t) { accentTypeByName[t.name] = t.accentType || t.name; });
function accentForName(n) { return accentFor(accentTypeByName[n] || n); }
// Stage-column headers
layout.ranks.forEach(function(r, colIdx) {
const colX = layout.pad_left + colIdx * (layout.box_w + layout.col_gap);
const label = stageLabel(r);
if (!label) return;
root.append('text')
.attr('class', 'sd-stage-header')
.attr('x', colX + layout.box_w / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.text(label);
});
// ── Edges: render as Bezier curves between box anchors ─────────
// For each unique source_type → target_type pair, group edges by
// relation so multiple relations between the same pair stack
// vertically next to the midpoint.
const positions = layout.positions;
const edgeGroups = {};
model.edgeTypes.forEach(function(e) {
if (!positions[e.source_type] || !positions[e.target_type]) return;
const key = e.source_type + '|' + e.target_type;
if (!edgeGroups[key]) edgeGroups[key] = [];
edgeGroups[key].push(e);
});
const drawnEdges = [];
Object.keys(edgeGroups).forEach(function(key) {
const group = edgeGroups[key];
group.forEach(function(e, idxInPair) {
const src = positions[e.source_type];
const tgt = positions[e.target_type];
const srcCx = src.x + src.w / 2;
const srcCy = src.y + src.h / 2;
const tgtCx = tgt.x + tgt.w / 2;
const tgtCy = tgt.y + tgt.h / 2;
const isSelf = src === tgt;
let pathD;
let labelX, labelY;
let sx, sy, tx, ty;
if (isSelf) {
// Self-loop on the right side of the box
const lx = src.x + src.w;
pathD = 'M ' + lx + ' ' + (src.y + 30) +
' C ' + (lx + 60) + ' ' + (src.y + 10) +
', ' + (lx + 60) + ' ' + (src.y + src.h - 10) +
', ' + lx + ' ' + (src.y + src.h - 30);
labelX = lx + 36;
labelY = src.y + src.h / 2;
sx = lx; sy = src.y + 30; tx = lx; ty = src.y + src.h - 30;
} else if (Math.abs(src.x - tgt.x) < LAYOUT.BOX_W * 0.5) {
// Same-column pair (vertically stacked cards — common in the tall
// Entity column). The horizontal-ease curve degenerates into an
// S-loop here, leaving arrowheads pointing backwards. Route a side
// loop instead: exit the source's right edge, bulge outward, enter
// the target's right edge — the head always points cleanly left
// into the target card.
const offset = (idxInPair - (group.length - 1) / 2) * 22;
const sxp = src.x + src.w, syp = src.y + Math.min(34, src.h / 2);
const txp = tgt.x + tgt.w, typ = tgt.y + Math.min(34, tgt.h / 2);
const bulge = 56 + Math.min(150, Math.abs(typ - syp) * 0.14) + Math.abs(offset);
// Straight run-in/run-out segments at both ends: the arrow marker
// orients to the path tangent at its endpoint, so it must sit on a
// visibly straight piece of line or the head looks detached.
const RUN = 10;
pathD = 'M ' + sxp + ' ' + syp +
' L ' + (sxp + RUN) + ' ' + syp +
' C ' + (sxp + bulge) + ' ' + syp +
', ' + (txp + bulge) + ' ' + typ +
', ' + (txp + RUN) + ' ' + typ +
' L ' + txp + ' ' + typ;
labelX = Math.max(sxp, txp) + bulge * 0.78;
labelY = (syp + typ) / 2;
sx = sxp; sy = syp; tx = txp; ty = typ;
} else {
const aSrc = boxAnchor(src, tgtCx, tgtCy);
const aTgt = boxAnchor(tgt, srcCx, srcCy);
// Offset multiple relations between the same pair so labels don't overlap
const offset = (idxInPair - (group.length - 1) / 2) * 24;
// Horizontal-ease cubic that lifts above the cards for multi-column
// spans, so the edge arcs over intermediate cards (Change 2).
const curve = flowCurve(aSrc.x, aSrc.y, aTgt.x, aTgt.y, offset);
pathD = curve.d;
labelX = curve.mx;
labelY = curve.my;
sx = aSrc.x; sy = aSrc.y; tx = aTgt.x; ty = aTgt.y;
}
drawnEdges.push({ edge: e, path: pathD, labelX: labelX, labelY: labelY,
sx: sx, sy: sy, tx: tx, ty: ty });
});
});
const defs = svg.append('defs');
// Soft drop shadow for the type cards.
const shadow = defs.append('filter')
.attr('id', 'sd-card-shadow')
.attr('x', '-30%').attr('y', '-30%')
.attr('width', '160%').attr('height', '180%');
shadow.append('feDropShadow')
.attr('dx', 0).attr('dy', 2)
.attr('stdDeviation', 5)
.attr('flood-color', P.shadow)
.attr('flood-opacity', P.shadowOpacity);
// Per-edge gradient: flows from the source type's accent to the target's.
function gradId(e){ return 'sd-grad-' + String(e.id).replace(/[^a-zA-Z0-9_-]/g, ''); }
drawnEdges.forEach(function(de) {
const grad = defs.append('linearGradient')
.attr('id', gradId(de.edge))
.attr('gradientUnits', 'userSpaceOnUse')
.attr('x1', de.sx).attr('y1', de.sy)
.attr('x2', de.tx).attr('y2', de.ty);
grad.append('stop').attr('offset', '0%')
.attr('stop-color', accentForName(de.edge.source_type));
grad.append('stop').attr('offset', '100%')
.attr('stop-color', accentForName(de.edge.target_type));
});
// Arrow markers, tinted per target type so the head matches the flow.
const markerTypes = {};
drawnEdges.forEach(function(de) { markerTypes[accentTypeByName[de.edge.target_type] || de.edge.target_type] = true; });
function arrowId(tname){ return 'sd-arrow-' + String(tname).replace(/[^a-zA-Z0-9_-]/g, ''); }
Object.keys(markerTypes).forEach(function(tname) {
// Tip at viewBox x=10 with refX=10: the tip sits exactly on the path
// endpoint with the head body covering the line behind it — no empty
// marker margin past the tip (which left the path's round linecap
// poking out as a detached nub).
defs.append('marker')
.attr('id', arrowId(tname))
.attr('viewBox', '0 -5 10 10')
.attr('refX', 10).attr('refY', 0)
.attr('markerWidth', 7).attr('markerHeight', 7)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-4L10,0L0,4')
.attr('fill', accentFor(tname));
});
// Draw edge paths first so labels render on top
const edgeGroup = root.append('g').attr('class', 'sd-edges');
drawnEdges.forEach(function(de) {
edgeGroup.append('path')
.datum(de.edge)
.attr('class', 'sd-edge-path')
.attr('data-edge-id', de.edge.id)
.attr('data-source-type', de.edge.source_type)
.attr('data-target-type', de.edge.target_type)
.attr('d', de.path)
.attr('stroke', 'url(#' + gradId(de.edge) + ')')
.attr('marker-end', 'url(#' + arrowId(accentTypeByName[de.edge.target_type] || de.edge.target_type) + ')');
});
// Edge labels
const labelGroup = root.append('g').attr('class', 'sd-edge-labels');
drawnEdges.forEach(function(de) {
const rel = primaryRelation(de.edge);
const cnt = de.edge.edge_count ? '×' + de.edge.edge_count : '';
const labelText = rel + (cnt ? ' ' + cnt : '');
// Background pill so the label reads cleanly over crossing arrows
const tmp = labelGroup.append('text')
.attr('class', 'sd-edge-label')
.text(labelText)
.style('visibility', 'hidden');
const bbox = tmp.node().getBBox();
tmp.remove();
const pad = 6;
labelGroup.append('rect')
.attr('class', 'sd-edge-label-bg')
.attr('data-edge-id', de.edge.id)
.attr('x', de.labelX - bbox.width / 2 - pad)
.attr('y', de.labelY - bbox.height / 2 - 1)
.attr('width', bbox.width + pad * 2)
.attr('height', bbox.height + 2)
.attr('rx', 4);
labelGroup.append('text')
.attr('class', 'sd-edge-label')
.attr('data-edge-id', de.edge.id)
.attr('x', de.labelX)
.attr('y', de.labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.text(labelText);
});
// ── Node-type boxes ────────────────────────────────────────────
const nodeGroup = root.append('g').attr('class', 'sd-nodes');
Object.keys(positions).forEach(function(name) {
const p = positions[name];
const t = p.type;
const accent = accentForName(name);
// Record the box rect for the spotlight overlay (fallback anchor).
window._boxRectByType[name] = { x: p.x, y: p.y, w: p.w, h: p.h };
const g = nodeGroup.append('g')
.attr('class', 'sd-node-box')
.attr('data-node-type', name)
.attr('data-node-id', t.id || '')
.attr('transform', 'translate(' + p.x + ',' + p.y + ')');
// Card body — soft themed card with a gentle drop shadow.
g.append('rect')
.attr('width', p.w)
.attr('height', p.h)
.attr('rx', 14)
.attr('fill', P.card)
.attr('stroke', P.cardBorder)
.attr('stroke-width', 1)
.attr('filter', 'url(#sd-card-shadow)');
if (t.instance_count == null) {
// Single-instance card: the instance name is the headline.
g.append('circle').attr('cx', 21).attr('cy', 25).attr('r', 5).attr('fill', accent);
g.append('text').attr('x', 34).attr('y', 30)
.attr('font-size', 14.5).attr('font-weight', 700).attr('fill', P.title)
.text(truncate(name, 15));
return;
}
// Type card: the column header above already names the category, so the
// type label is small + muted — the instance mini-cards are the focus.
g.append('circle').attr('cx', 18).attr('cy', 22).attr('r', 4).attr('fill', accent);
g.append('text').attr('x', 30).attr('y', 25.5)
.attr('font-size', 10.5).attr('font-weight', 700).attr('letter-spacing', '0.04em')
.attr('fill', P.muted)
.text(truncate(String(name).toUpperCase(), 18));
// Count pill (top-right).
const cstr = String(t.instance_count);
const pw = 16 + cstr.length * 8;
g.append('rect')
.attr('x', p.w - 14 - pw).attr('y', 13)
.attr('width', pw).attr('height', 19).attr('rx', 9.5)
.attr('fill', P.pill);
g.append('text')
.attr('x', p.w - 14 - pw / 2).attr('y', 26.5)
.attr('text-anchor', 'middle')
.attr('font-size', 11).attr('font-weight', 600).attr('fill', P.pillText)
.text(cstr);
// "Modified by operations" badge (amber dot) — see the Transformations layer.
if (modifiedTypes.has(name)) {
const dot = g.append('circle')
.attr('cx', p.w - 14 - pw - 11).attr('cy', 22.5).attr('r', 3.5)
.attr('fill', '#F2994A');
dot.append('title').text('Modified by transformation operations');
}
// Instance mini-cards — capped at MAX_CARDS unless this type is expanded.
const insts = instancesForType(name);
const expanded = isTypeExpanded(name);
const limit = expanded ? insts.length : LAYOUT.MAX_CARDS;
insts.slice(0, limit).forEach(function(inst, i) {
const cy = LAYOUT.HEADER_H + i * LAYOUT.CARD_H;
window._miniCardRects[inst.id] =
{ x: p.x + 13, y: p.y + cy, w: p.w - 26, h: LAYOUT.CARD_H - 6 };
const card = g.append('g')
.attr('class', 'sd-mini-card')
.attr('data-iid', inst.id);
card.append('rect')
.attr('x', 13).attr('y', cy)
.attr('width', p.w - 26).attr('height', LAYOUT.CARD_H - 6)
.attr('rx', 7)
.attr('fill', P.mini)
.attr('stroke', P.miniBorder)
.attr('stroke-width', 1);
card.append('text')
.attr('x', 25).attr('y', cy + (LAYOUT.CARD_H - 6) / 2 + 4)
.attr('font-size', 11.5)
.attr('fill', P.miniText)
.text(truncate(inst.name || inst.id, 24));
});
const total = t.instance_count || insts.length;
if (total > LAYOUT.MAX_CARDS) {
const shownCount = Math.min(limit, insts.length);
const my = LAYOUT.HEADER_H + shownCount * LAYOUT.CARD_H;
const toggle = g.append('g')
.attr('class', 'sd-expand-toggle')
.attr('data-type', name);
// Invisible hit area so the whole toggle row is clickable.
toggle.append('rect')
.attr('x', 13).attr('y', my).attr('width', p.w - 26).attr('height', 16)
.attr('fill', 'transparent');
toggle.append('text')
.attr('x', 21).attr('y', my + 12)
.attr('font-size', 11).attr('font-weight', 600).attr('fill', '#1F9E6E')
.text(expanded ? 'Show less' : ('+ ' + (total - LAYOUT.MAX_CARDS) + ' more'));
}
});
// ── Transformation operation rail (impact-layer) ────────────────
window._opRects = {};
const ops = window._schemaOperations || [];
if (window._schemaShowOps && ops.length) {
const xs = Object.keys(positions).map(function(k) { return positions[k].x; });
const minX = xs.length ? Math.min.apply(null, xs) : LAYOUT.PAD_LEFT;
const maxRight = (xs.length ? Math.max.apply(null, xs) : 800) + LAYOUT.BOX_W;
const railY = -120, chipW = 150, chipH = 30;
const availW = Math.max(maxRight - minX, ops.length * (chipW + 16));
const opGroup = root.append('g').attr('class', 'sd-op-rail');
ops.forEach(function(op, i) {
const x = minX + i * (availW / ops.length);
window._opRects[op.id] = { x: x, y: railY, w: chipW, h: chipH };
const kindColor = OP_KIND_COLORS[op.op_kind] || '#8792A2';
const g = opGroup.append('g').attr('class', 'sd-op-chip').attr('data-op', op.id);
g.append('rect').attr('x', x).attr('y', railY).attr('width', chipW).attr('height', chipH)
.attr('rx', 8).attr('fill', P.card).attr('stroke', kindColor).attr('stroke-width', 1.5)
.attr('filter', 'url(#sd-card-shadow)');
g.append('circle').attr('cx', x + 13).attr('cy', railY + chipH / 2).attr('r', 4).attr('fill', kindColor);
g.append('text').attr('x', x + 24).attr('y', railY + chipH / 2 + 4)
.attr('font-size', 11.5).attr('font-weight', 600).attr('fill', P.title)
.text(truncate(op.name, 16));
});
}
// ── Selection wiring ───────────────────────────────────────────
let selected = null;
const selectionEl = document.getElementById('schema-selection');
function applySelection() {
svg.selectAll('.sd-node-box').classed('is-dim', false);
svg.selectAll('.sd-edge-path').classed('is-hot', false).classed('is-dim', false);
svg.selectAll('.sd-edge-label-bg').classed('is-hot', false).classed('is-dim', false);
svg.selectAll('.sd-edge-label').classed('is-dim', false);
if (selectionEl) selectionEl.textContent = '';
if (!selected) return;
if (selected.kind === 'node') {
const name = selected.name;
svg.selectAll('.sd-node-box').classed('is-dim', function() { return this.dataset.nodeType !== name; });
svg.selectAll('.sd-edge-path').classed('is-hot', function() {
return this.dataset.sourceType === name || this.dataset.targetType === name;
}).classed('is-dim', function() {
return this.dataset.sourceType !== name && this.dataset.targetType !== name;
});
svg.selectAll('.sd-edge-label-bg').each(function() {
const id = this.getAttribute('data-edge-id');
const path = svg.select('.sd-edge-path[data-edge-id="' + id + '"]').node();
if (path) {
this.classList.toggle('is-hot', path.classList.contains('is-hot'));
this.classList.toggle('is-dim', path.classList.contains('is-dim'));
}
});
svg.selectAll('.sd-edge-label').each(function() {
const id = this.getAttribute('data-edge-id');
const path = svg.select('.sd-edge-path[data-edge-id="' + id + '"]').node();
if (path) this.classList.toggle('is-dim', path.classList.contains('is-dim'));
});
if (selectionEl) selectionEl.textContent = 'Selected node type: ' + name + ' — click again to clear';
} else if (selected.kind === 'edge') {
const id = selected.id;
const edgePath = svg.select('.sd-edge-path[data-edge-id="' + id + '"]');
const sourceType = edgePath.attr('data-source-type');
const targetType = edgePath.attr('data-target-type');
svg.selectAll('.sd-node-box').classed('is-dim', function() {
return this.dataset.nodeType !== sourceType && this.dataset.nodeType !== targetType;
});
svg.selectAll('.sd-edge-path').classed('is-dim', function() { return this.getAttribute('data-edge-id') !== id; });
edgePath.classed('is-dim', false).classed('is-hot', true);
svg.select('.sd-edge-label-bg[data-edge-id="' + id + '"]').classed('is-hot', true);
if (selectionEl) selectionEl.textContent = 'Selected edge: ' + sourceType + ' → ' + targetType + ' — click again to clear';
}
}
svg.selectAll('.sd-node-box').on('click', function() {
const name = this.dataset.nodeType;
// A type-box click is a type-level selection; clear any instance spotlight.
if (window._schemaSpotlight) { window._schemaSpotlight = null; clearSpotlight(); }
if (selected && selected.kind === 'node' && selected.name === name) {
selected = null;
if (window._hideSchemaInspector) window._hideSchemaInspector();
updateSchemaStatus(null);
} else {
selected = { kind: 'node', name: name };
// Open the type inspector side panel for the clicked type.
if (window._showSchemaInspector) window._showSchemaInspector(name);
}
applySelection();
});
// Instance mini-cards: click focuses the diagram on that instance and opens
// its inspector (stop propagation so the parent type box doesn't also fire).
svg.selectAll('.sd-mini-card').on('click', function(event) {
if (event) event.stopPropagation();
const iid = this.dataset.iid;
if (!iid) return;
if (window._showSchemaInstanceInspector) window._showSchemaInstanceInspector(iid);
if (window._focusSchemaInstance) window._focusSchemaInstance(iid);
});
// Click an operation chip → spotlight the types it transforms.
svg.selectAll('.sd-op-chip').on('click', function(event) {
if (event) event.stopPropagation();
const opId = this.dataset.op;
if (opId) applyOpSpotlight(opId);
});
// Expand / collapse the instance list inside a type box.
svg.selectAll('.sd-expand-toggle').on('click', function(event) {
if (event) event.stopPropagation();
const tp = this.dataset.type;
window._schemaExpanded = window._schemaExpanded || {};
window._schemaExpanded[tp] = !window._schemaExpanded[tp];
window._renderSchemaGraph(true); // re-layout, preserving the current pan/zoom
});
svg.selectAll('.sd-edge-path, .sd-edge-label-bg, .sd-edge-label').on('click', function() {
const id = this.getAttribute('data-edge-id');
if (!id) return;
if (selected && selected.kind === 'edge' && selected.id === id) { selected = null; }
else { selected = { kind: 'edge', id: id }; }
applySelection();
});
// ── Pan / zoom on the schema canvas ─────────────────────────────
schemaSvgSel = svg;
schemaRootSel = root;
schemaZoomBehavior = d3.zoom()
.scaleExtent([0.2, 3])
.on('start', function() { svg.classed('is-panning', true); })
.on('zoom', function(event) { root.attr('transform', event.transform); })
.on('end', function() { svg.classed('is-panning', false); });
svg.call(schemaZoomBehavior).on('dblclick.zoom', null);
if (savedTransform) {
// Preserve the current pan/zoom across re-renders (e.g. expand/collapse).
svg.call(schemaZoomBehavior.transform, savedTransform);
} else {
// Initial framing: fit the whole diagram into the viewport.
fitSchema(null, false);
}
}
// ── Empty / non-empty state plumbing ─────────────────────────────
function setSchemaEmpty(message) {
emptyEl.style.display = 'flex';
emptyEl.querySelector('div').textContent = message || 'No schema configured for this dataset.';
if (svgEl) svgEl.style.display = 'none';
const hdr = document.getElementById('schema-header');
const dia = document.getElementById('schema-diagram-section');
if (hdr) hdr.style.display = 'none';
if (dia) dia.style.display = 'none';
}
function hideSchemaEmpty() {
emptyEl.style.display = 'none';
if (svgEl) svgEl.style.display = '';
const hdr = document.getElementById('schema-header');
const dia = document.getElementById('schema-diagram-section');
if (hdr) hdr.style.display = '';
if (dia) dia.style.display = '';
}
// ── Pan / zoom ───────────────────────────────────────────────────
// d3.zoom on the SVG, applied to the root <g>, so the schema can be panned
// and zoomed like the graph view. Fit accounts for the side panel so the
// spotlighted connections land in the visible area, not under the panel.
let schemaSvgSel = null, schemaRootSel = null, schemaZoomBehavior = null;
function panelOffset() {
const panel = document.getElementById('schema-side-panel');
return panel && panel.style.display !== 'none' ? (panel.offsetWidth || 344) : 0;
}
function fitSchema(bbox, animate) {
if (!schemaSvgSel || !schemaRootSel || !schemaZoomBehavior) return;
const node = schemaSvgSel.node();
const W = node.clientWidth || node.getBoundingClientRect().width || 1200;
const H = node.clientHeight || node.getBoundingClientRect().height || 700;
const avail = Math.max(240, W - panelOffset());
if (!bbox) {
try { bbox = schemaRootSel.node().getBBox(); } catch (e) { return; }
}
const pad = 44;
const bw = bbox.width || 1, bh = bbox.height || 1;
const scale = Math.max(0.2, Math.min((avail - pad * 2) / bw, (H - pad * 2) / bh, 1.5));
const tx = pad + (avail - pad * 2 - bw * scale) / 2 - bbox.x * scale;
const ty = pad + (H - pad * 2 - bh * scale) / 2 - bbox.y * scale;
const t = d3.zoomIdentity.translate(tx, ty).scale(scale);
const target = animate ? schemaSvgSel.transition().duration(420) : schemaSvgSel;
target.call(schemaZoomBehavior.transform, t);
}
// ── Instance spotlight ───────────────────────────────────────────
// Clicking an instance keeps the full diagram and spotlights that instance's
// connections: its card + linked cards pop, everything else dims, and glowing
// connector edges are drawn from it to its neighbours. window._schemaSpotlight
// holds the focused instance id (or null).
window._schemaSpotlight = null;
// ── Transformation impact-layer ──────────────────────────────────
// Operations (cognify, memify, improve, …) shown as a rail of chips with
// color-coded edges to the schema types they affect. Off by default.
window._schemaShowOps = window._schemaShowOps || false;
window._opRects = window._opRects || {};
const EFFECT_COLORS = {
produces: '#1F9E6E', enriches: '#2F80ED', modifies: '#F2994A', removes: '#EB5757',
};
const EFFECT_DASH = { modifies: '6 4', removes: '6 4' };
const OP_KIND_COLORS = {
pipeline: '#2F80ED', self_improve: '#F2994A', lifecycle: '#EB5757',
};
function modifiedTypeNames() {
const names = new Set();
(window._schemaOperationLinks || []).forEach(function(l) {
if (l.effect === 'modifies') names.add(String(l.target).replace(/^type:/, ''));
});
return names;
}
// Anchor two rects on their facing sides (left→right flow).
function anchorBetween(s, t) {
const sCx = s.x + s.w / 2, tCx = t.x + t.w / 2;
if (tCx >= sCx) {
return { sx: s.x + s.w, sy: s.y + s.h / 2, tx: t.x, ty: t.y + t.h / 2 };
}
return { sx: s.x, sy: s.y + s.h / 2, tx: t.x + t.w, ty: t.y + t.h / 2 };
}
// Horizontal-ease cubic; for multi-column spans it drops the curve below the
// cards so it arcs under the intermediate cards instead of cutting through them.
function flowCurve(sx, sy, tx, ty, offset) {
offset = offset || 0;
const dx = tx - sx;
const dy = Math.abs(ty - sy);
const span = Math.abs(dx) / (LAYOUT.BOX_W + LAYOUT.COL_GAP);
const handle = Math.max(60, Math.abs(dx) * 0.5);
const dir = dx >= 0 ? 1 : -1;
let c1y = sy + offset, c2y = ty + offset;
// Drop the control points below the card band so multi-column edges arc
// clearly under the intermediate cards rather than grazing them. Only
// when the endpoints sit at similar heights: with a large vertical span
// (tall columns) the drop overshot below BOTH cards, hooking the curve
// back up so the arrowhead pointed the wrong way ("inverted" arrows).
// Vertically distant cards get the plain horizontal-ease instead, which
// already clears the band diagonally.
if (span >= 1.25 && dy < 140) {
const drop = Math.max(sy, ty) + (64 + span * 40);
c1y = drop + offset;
c2y = drop + offset;
} else if (span >= 0.4 && dy < 90) {
// Adjacent columns: a gentle downward bow so the edge clears the card edges.
const drop = Math.max(sy, ty) + 34;
c1y = drop + offset;
c2y = drop + offset;
}
const c1x = sx + dir * handle, c2x = tx - dir * handle;
// Straight run-in/run-out segments: the arrow marker orients to the path
// tangent at the endpoint, so it must sit on a visibly straight piece of
// line or the head appears detached/rotated off the curve.
const RUN = 10;
const sx2 = sx + dir * RUN, tx2 = tx - dir * RUN;
return {
d: 'M ' + sx + ' ' + sy +
' L ' + sx2 + ' ' + sy +
' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + tx2 + ' ' + ty +
' L ' + tx + ' ' + ty,
mx: (c1x + c2x) / 2,
my: (c1y + c2y) / 2,
};
}
function updateSchemaStatus(focusId) {
const selEl = document.getElementById('schema-selection');
if (!selEl) return;
selEl.textContent = '';
if (focusId) {
const name = ((window._schemaInstanceIndex || {})[focusId] || {}).name || focusId;
const label = document.createElement('span');
label.textContent = 'Focused on ' + name + ' ';
label.style.cssText = 'color:#1A1F36;font-weight:600;';
const clear = document.createElement('span');
clear.textContent = 'Clear focus';
clear.style.cssText = 'color:#1F9E6E;cursor:pointer;font-weight:600;';
clear.addEventListener('click', window._clearSchemaLens);
selEl.appendChild(label);
selEl.appendChild(clear);
} else {
selEl.style.color = '#8792A2';
selEl.textContent = 'Click any instance card to trace its connections.';
}
}
function clearSpotlight() {
const svg = d3.select('#schema-svg');
svg.select('g.sd-spotlight').remove();
svg.selectAll('.sd-node-box, .sd-mini-card')
.classed('is-dim', false).classed('is-spotlit', false).classed('is-linked', false);
svg.selectAll('.sd-edge-path, .sd-edge-label-bg, .sd-edge-label').classed('is-dim', false);
svg.selectAll('.sd-op-chip').classed('is-dim', false).classed('is-spotlit', false);
}
// Spotlight a transformation operation: highlight the types it affects and
// draw color-coded impact edges (produces/enriches/modifies/removes).
function applyOpSpotlight(opId) {
const svg = d3.select('#schema-svg');
clearSpotlight();
const links = (window._schemaOperationLinks || []).filter(function(l) { return l.source === opId; });
if (!links.length) { updateSchemaStatus(null); return; }
const targetNames = new Set(links.map(function(l) { return String(l.target).replace(/^type:/, ''); }));
svg.selectAll('.sd-node-box').classed('is-dim', function() { return !targetNames.has(this.dataset.nodeType); });
svg.selectAll('.sd-mini-card').classed('is-dim', true);
svg.selectAll('.sd-edge-path, .sd-edge-label-bg, .sd-edge-label').classed('is-dim', true);
svg.selectAll('.sd-op-chip').classed('is-dim', function() { return this.dataset.op !== opId; })
.classed('is-spotlit', function() { return this.dataset.op === opId; });
const opRect = (window._opRects || {})[opId];
const boxRects = window._boxRectByType || {};
const fitRects = [];
if (opRect) {
fitRects.push(opRect);
const overlay = (schemaRootSel || svg).append('g').attr('class', 'sd-spotlight');
links.forEach(function(l) {
const tname = String(l.target).replace(/^type:/, '');
const r = boxRects[tname];
if (!r) return;
fitRects.push(r);
const color = EFFECT_COLORS[l.effect] || '#8792A2';
// Vertical-ease cubic: operation chip (top) feeds down into the type card.
const sx = opRect.x + opRect.w / 2, sy = opRect.y + opRect.h;
const tx = r.x + r.w / 2, ty = r.y;
const midy = (sy + ty) / 2;
const d = 'M ' + sx + ' ' + sy + ' C ' + sx + ' ' + midy + ', ' + tx + ' ' + midy + ', ' + tx + ' ' + ty;
overlay.append('path').attr('class', 'sd-spotlight-edge-halo').attr('fill', 'none').attr('stroke', color).attr('d', d);
const path = overlay.append('path').attr('class', 'sd-op-edge').attr('fill', 'none').attr('stroke', color).attr('d', d);
if (EFFECT_DASH[l.effect]) path.attr('stroke-dasharray', EFFECT_DASH[l.effect]);
});
}
// Frame the operation + its targets in view.
if (fitRects.length) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
fitRects.forEach(function(r) {
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y);
x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h);
});
fitSchema({ x: x0 - 40, y: y0 - 40, width: (x1 - x0) + 80, height: (y1 - y0) + 80 }, true);
}
const op = (window._schemaOperations || []).find(function(o) { return o.id === opId; });
updateOpStatus(op);
}
function updateOpStatus(op) {
const selEl = document.getElementById('schema-selection');
if (!selEl) return;
selEl.textContent = '';
if (!op) { updateSchemaStatus(null); return; }
const label = document.createElement('span');
label.textContent = (op.summary || op.name) + ' ';
label.style.cssText = 'color:#1A1F36;font-weight:600;';
const clear = document.createElement('span');
clear.textContent = 'Clear';
clear.style.cssText = 'color:#1F9E6E;cursor:pointer;font-weight:600;';
clear.addEventListener('click', window._clearSchemaLens);
selEl.appendChild(label);
selEl.appendChild(clear);
}
function applySpotlight(focusId) {
const index = window._schemaInstanceIndex || {};
const focus = index[focusId];
const svg = d3.select('#schema-svg');
clearSpotlight();
if (!focus) { updateSchemaStatus(null); return; }
const links = [];
(focus.out || []).forEach(function(e) { links.push({ id: e.id, relation: e.relation, dir: 'out' }); });
(focus.in || []).forEach(function(e) { links.push({ id: e.id, relation: e.relation, dir: 'in' }); });
const linkedIds = {}; linkedIds[focusId] = true;
links.forEach(function(l) { linkedIds[l.id] = true; });
const activeTypes = {};
Object.keys(linkedIds).forEach(function(id) { const n = index[id]; if (n) activeTypes[n.type] = true; });
svg.selectAll('.sd-mini-card')
.classed('is-dim', function() { return !linkedIds[this.dataset.iid]; })
.classed('is-spotlit', function() { return this.dataset.iid === focusId; })
.classed('is-linked', function() { return this.dataset.iid !== focusId && !!linkedIds[this.dataset.iid]; });
svg.selectAll('.sd-node-box').classed('is-dim', function() { return !activeTypes[this.dataset.nodeType]; });
svg.selectAll('.sd-edge-path, .sd-edge-label-bg, .sd-edge-label').classed('is-dim', true);
const rects = window._miniCardRects || {};
const boxRects = window._boxRectByType || {};
const focusRect = rects[focusId] || boxRects[focus.type];
if (focusRect) {
// Append inside the zoomable root so the overlay pans/zooms with the cards.
const overlay = (schemaRootSel || svg).append('g').attr('class', 'sd-spotlight');
const accent = accentFor(focus.type);
const marker = overlay.append('defs').append('marker')
.attr('id', 'sd-spot-arrow').attr('viewBox', '0 -5 10 10')
.attr('refX', 10).attr('refY', 0).attr('markerWidth', 7).attr('markerHeight', 7)
.attr('orient', 'auto');
marker.append('path').attr('d', 'M0,-4L10,0L0,4').attr('fill', accent);
let sameCardIdx = 0;
let crossCardIdx = 0;
// Greedy vertical declutter: connector labels for links to nearby
// instances land on nearly the same midpoint and overprint into
// garbage. Push each new label below any already-placed neighbor.
const placedLabelYs = [];
function declutterY(y) {
let moved = true;
while (moved) {
moved = false;
for (let i = 0; i < placedLabelYs.length; i++) {
if (Math.abs(y - placedLabelYs[i]) < 13) { y = placedLabelYs[i] + 13; moved = true; }
}
}
placedLabelYs.push(y);
return y;
}
links.forEach(function(l) {
const n = index[l.id];
if (!n) return;
const tgtRect = rects[l.id] || boxRects[n.type];
if (!tgtRect) return;
let s = focusRect, t = tgtRect;
if (l.dir === 'in') { s = tgtRect; t = focusRect; }
let d, lx, ly, anchor = 'middle';
if (n.type === focus.type) {
// Connection between two instances of the SAME type card. The
// facing-side anchors degenerate here: the connector cut straight
// across the card with its label overprinting the others. Route a
// side loop out of the card's LEFT edge instead (the right side
// already hosts the type-pair loops), nesting loops and labels by
// index so multiple relations stay individually readable.
sameCardIdx += 1;
const sx = s.x, sy = s.y + s.h / 2;
const tx = t.x, ty = t.y + t.h / 2;
const bulge = 46 + Math.min(140, Math.abs(ty - sy) * 0.18) + sameCardIdx * 16;
const RUN = 8;
d = 'M ' + sx + ' ' + sy +
' L ' + (sx - RUN) + ' ' + sy +
' C ' + (sx - bulge) + ' ' + sy +
', ' + (tx - bulge) + ' ' + ty +
', ' + (tx - RUN) + ' ' + ty +
' L ' + tx + ' ' + ty;
// Right-align the label just left of the loop apex so the text
// extends away from the nested loop strokes.
lx = Math.min(sx, tx) - bulge - 4;
ly = declutterY((sy + ty) / 2);
anchor = 'end';
} else {
// Cross-card connector: spread vertically so labels between the
// same pair of cards don't land on one coincident midpoint.
crossCardIdx += 1;
const spread = ((crossCardIdx % 5) - 2) * 16;
const a = anchorBetween(s, t);
const curve = flowCurve(a.sx, a.sy, a.tx, a.ty, spread);
d = curve.d;
lx = curve.mx;
ly = declutterY(curve.my);
}
overlay.append('path').attr('class', 'sd-spotlight-edge-halo').attr('fill', 'none').attr('stroke', accent).attr('d', d);
overlay.append('path').attr('class', 'sd-spotlight-edge').attr('fill', 'none').attr('stroke', accent)
.attr('marker-end', 'url(#sd-spot-arrow)').attr('d', d);
overlay.append('text').attr('class', 'sd-spotlight-label')
.attr('x', lx).attr('y', ly).attr('text-anchor', anchor)
.attr('fill', accent).text(l.relation);
});
}
// Frame the spotlighted connections in the visible area (left of the panel)
// so they aren't hidden behind it.
const fitRects = [];
if (focusRect) fitRects.push(focusRect);
links.forEach(function(l) {
const n = index[l.id];
const r = rects[l.id] || (n && boxRects[n.type]);
if (r) fitRects.push(r);
});
if (fitRects.length) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
fitRects.forEach(function(r) {
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y);
x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h);
});
// Extra bottom padding for connector edges that arc below the cards.
fitSchema({ x: x0 - 40, y: y0 - 30, width: (x1 - x0) + 80, height: (y1 - y0) + 210 }, true);
}
updateSchemaStatus(focusId);
}
window._focusSchemaInstance = function(instanceId) {
if (!(window._schemaInstanceIndex || {})[instanceId]) return;
window._schemaSpotlight = instanceId;
applySpotlight(instanceId);
};
window._clearSchemaLens = function() {
window._schemaSpotlight = null;
clearSpotlight();
updateSchemaStatus(null);
fitSchema(null, true); // zoom back out to the whole diagram
};
window._renderSchemaGraph = function(preserveView) {
// Preserve the current pan/zoom when re-rendering in place (expand/collapse).
let saved = null;
if (preserveView && schemaSvgSel) {
try { saved = d3.zoomTransform(schemaSvgSel.node()); } catch (e) { saved = null; }
}
const model = buildSchemaModel();
if (!model.nodeTypes.length) {
setSchemaEmpty('No schema configured for this dataset.');
updateSchemaStatus(null);
return;
}
hideSchemaEmpty();
renderSchemaDiagram(model, saved);
// Re-apply an active spotlight after a fresh render (e.g. theme toggle).
if (window._schemaSpotlight && (window._schemaInstanceIndex || {})[window._schemaSpotlight]) {
applySpotlight(window._schemaSpotlight);
} else {
window._schemaSpotlight = null;
updateSchemaStatus(null);
}
};
// Zoom control buttons (wired once; act on the active zoom behavior).
(function () {
function bind(id, fn) {
const el = document.getElementById(id);
if (el) el.addEventListener('click', fn);
}
bind('schema-zoom-in', function () {
if (schemaSvgSel && schemaZoomBehavior) schemaSvgSel.transition().duration(180).call(schemaZoomBehavior.scaleBy, 1.4);
});
bind('schema-zoom-out', function () {
if (schemaSvgSel && schemaZoomBehavior) schemaSvgSel.transition().duration(180).call(schemaZoomBehavior.scaleBy, 1 / 1.4);
});
bind('schema-zoom-fit', function () { fitSchema(null, true); });
bind('schema-ops-toggle', function () {
window._schemaShowOps = !window._schemaShowOps;
const btn = document.getElementById('schema-ops-toggle');
const legend = document.getElementById('schema-legend');
if (btn) btn.classList.toggle('active', window._schemaShowOps);
if (legend) legend.style.display = window._schemaShowOps ? 'block' : 'none';
window._schemaSpotlight = null;
window._renderSchemaGraph(); // re-render + reframe to include/exclude the rail
});
})();
if (!schemaData && (!schemaGraphData || !schemaGraphData.nodes || !schemaGraphData.nodes.length)) {
setSchemaEmpty('No schema configured for this dataset.');
}
})();