项目文件夹

文件
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 行
51 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// 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.');
}
})();