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
1120 lines
51 KiB
JavaScript
1120 lines
51 KiB
JavaScript
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
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.');
|
||
}
|
||
})();
|