mirror of
https://github.com/perstarkse/minne.git
synced 2026-07-10 06:42:43 +02:00
refactor: split knowledge-graph monolith and extract rubberbanding logic
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Refactor: split knowledge-graph.js monolith into focused functions (loadGraphData, buildSvg, createSimulation, drawLinks/Nodes/Labels, createHighlighting, createZoom, attachResize); fixed dead duplicate zoom instance
|
||||||
|
- Refactor: extracted rubberbanding scroll logic in design-polish.js into standalone attachRubberbanding helper; removed dead pullDistance state
|
||||||
|
|
||||||
- Evaluations: simplified crate layout — linear pipeline, sharded-only converted store, in-memory ingestion, `db/` and `cli/` modules; namespace reuse state in corpus manifest (removed `cache/snapshots/`); no legacy JSON/history compatibility (re-run `--warm` after upgrade)
|
- Evaluations: simplified crate layout — linear pipeline, sharded-only converted store, in-memory ingestion, `db/` and `cli/` modules; namespace reuse state in corpus manifest (removed `cache/snapshots/`); no legacy JSON/history compatibility (re-run `--warm` after upgrade)
|
||||||
- Performance: ingestion skips per-task index rebuild; worker runs scheduled `REBUILD INDEX` (default every 24h via `index_rebuild_interval_secs`, `0` disables)
|
- Performance: ingestion skips per-task index rebuild; worker runs scheduled `REBUILD INDEX` (default every 24h via `index_rebuild_interval_secs`, `0` disables)
|
||||||
- Performance: ingestion persists all artifacts in a single SurrealDB transaction per task (atomic replace by task id)
|
- Performance: ingestion persists all artifacts in a single SurrealDB transaction per task (atomic replace by task id)
|
||||||
|
|||||||
@@ -8,43 +8,46 @@
|
|||||||
* - Rubberbanding Scroll
|
* - Rubberbanding Scroll
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function() {
|
(() => {
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// === SCROLL-LINKED NAVBAR SHADOW ===
|
// === SCROLL-LINKED NAVBAR SHADOW ===
|
||||||
function initScrollShadow() {
|
function initScrollShadow() {
|
||||||
const mainContent = document.querySelector('main');
|
const mainContent = document.querySelector("main");
|
||||||
const navbar = document.querySelector('nav');
|
const navbar = document.querySelector("nav");
|
||||||
if (!mainContent || !navbar) return;
|
if (!mainContent || !navbar) return;
|
||||||
|
|
||||||
mainContent.addEventListener('scroll', () => {
|
mainContent.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
() => {
|
||||||
const scrollTop = mainContent.scrollTop;
|
const scrollTop = mainContent.scrollTop;
|
||||||
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
|
const scrollHeight =
|
||||||
|
mainContent.scrollHeight - mainContent.clientHeight;
|
||||||
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
||||||
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
|
navbar.style.setProperty("--scroll-depth", scrollDepth.toFixed(2));
|
||||||
}, { passive: true });
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === HTMX SWAP ANIMATION ===
|
// === HTMX SWAP ANIMATION ===
|
||||||
function initHtmxSwapAnimation() {
|
function initHtmxSwapAnimation() {
|
||||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
document.body.addEventListener("htmx:afterSwap", (event) => {
|
||||||
let target = event.detail.target;
|
let target = event.detail.target;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
// If full body swap (hx-boost), animate only the main content
|
// If full body swap (hx-boost), animate only the main content
|
||||||
if (target.tagName === 'BODY') {
|
if (target.tagName === "BODY") {
|
||||||
const main = document.querySelector('main');
|
const main = document.querySelector("main");
|
||||||
if (main) target = main;
|
if (main) target = main;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only animate if target is valid and inside/is main content or a card/panel
|
// Only animate if target is valid and inside/is main content or a card/panel
|
||||||
// Avoid animating sidebar or navbar updates
|
// Avoid animating sidebar or navbar updates
|
||||||
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
|
if (target && (target.tagName === "MAIN" || target.closest("main"))) {
|
||||||
if (!target.classList.contains('animate-fade-up')) {
|
if (!target.classList.contains("animate-fade-up")) {
|
||||||
target.classList.add('animate-fade-up');
|
target.classList.add("animate-fade-up");
|
||||||
// Remove class after animation completes to allow re-animation
|
// Remove class after animation completes to allow re-animation
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
target.classList.remove('animate-fade-up');
|
target.classList.remove("animate-fade-up");
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,22 +56,18 @@
|
|||||||
|
|
||||||
// === TYPEWRITER AI RESPONSE ===
|
// === TYPEWRITER AI RESPONSE ===
|
||||||
// Works with SSE streaming - buffers text and reveals character by character
|
// Works with SSE streaming - buffers text and reveals character by character
|
||||||
window.initTypewriter = function(element, options = {}) {
|
window.initTypewriter = (element, options = {}) => {
|
||||||
const {
|
const { minDelay = 5, maxDelay = 15, showCursor = true } = options;
|
||||||
minDelay = 5,
|
|
||||||
maxDelay = 15,
|
|
||||||
showCursor = true
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let buffer = '';
|
let buffer = "";
|
||||||
let isTyping = false;
|
let isTyping = false;
|
||||||
let cursorElement = null;
|
let cursorElement = null;
|
||||||
|
|
||||||
if (showCursor) {
|
if (showCursor) {
|
||||||
cursorElement = document.createElement('span');
|
cursorElement = document.createElement("span");
|
||||||
cursorElement.className = 'typewriter-cursor';
|
cursorElement.className = "typewriter-cursor";
|
||||||
cursorElement.textContent = '▌';
|
cursorElement.textContent = "▌";
|
||||||
cursorElement.style.animation = 'blink 1s step-end infinite';
|
cursorElement.style.animation = "blink 1s step-end infinite";
|
||||||
element.appendChild(cursorElement);
|
element.appendChild(cursorElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,13 +94,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
append: function(text) {
|
append: (text) => {
|
||||||
buffer += text;
|
buffer += text;
|
||||||
if (!isTyping) {
|
if (!isTyping) {
|
||||||
typeNextChar();
|
typeNextChar();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
complete: function() {
|
complete: () => {
|
||||||
// Flush remaining buffer immediately
|
// Flush remaining buffer immediately
|
||||||
if (cursorElement && cursorElement.parentNode) {
|
if (cursorElement && cursorElement.parentNode) {
|
||||||
const textNode = document.createTextNode(buffer);
|
const textNode = document.createTextNode(buffer);
|
||||||
@@ -110,57 +109,82 @@
|
|||||||
} else {
|
} else {
|
||||||
element.textContent += buffer;
|
element.textContent += buffer;
|
||||||
}
|
}
|
||||||
buffer = '';
|
buffer = "";
|
||||||
isTyping = false;
|
isTyping = false;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// === RUBBERBANDING SCROLL ===
|
// === RUBBERBANDING SCROLL ===
|
||||||
function initRubberbanding() {
|
function attachRubberbanding(
|
||||||
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
|
container,
|
||||||
|
{ maxPull = 60, resistance = 0.4 } = {},
|
||||||
containers.forEach(container => {
|
) {
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let pulling = false;
|
let pulling = false;
|
||||||
let pullDistance = 0;
|
|
||||||
const maxPull = 60;
|
|
||||||
const resistance = 0.4;
|
|
||||||
|
|
||||||
container.addEventListener('touchstart', (e) => {
|
function applyPull(distance) {
|
||||||
startY = e.touches[0].clientY;
|
container.style.transform = `translateY(${distance}px)`;
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
container.addEventListener('touchmove', (e) => {
|
|
||||||
const currentY = e.touches[0].clientY;
|
|
||||||
const diff = currentY - startY;
|
|
||||||
|
|
||||||
// At top boundary, pulling down
|
|
||||||
if (container.scrollTop <= 0 && diff > 0) {
|
|
||||||
pulling = true;
|
|
||||||
pullDistance = Math.min(diff * resistance, maxPull);
|
|
||||||
container.style.transform = `translateY(${pullDistance}px)`;
|
|
||||||
}
|
}
|
||||||
// At bottom boundary, pulling up
|
|
||||||
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
|
|
||||||
pulling = true;
|
|
||||||
pullDistance = Math.max(diff * resistance, -maxPull);
|
|
||||||
container.style.transform = `translateY(${pullDistance}px)`;
|
|
||||||
}
|
|
||||||
}, { passive: true });
|
|
||||||
|
|
||||||
container.addEventListener('touchend', () => {
|
function release() {
|
||||||
if (pulling) {
|
container.style.transition =
|
||||||
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
|
"transform 300ms cubic-bezier(0.25, 1, 0.5, 1)";
|
||||||
container.style.transform = 'translateY(0)';
|
container.style.transform = "translateY(0)";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
container.style.transition = '';
|
container.style.transition = "";
|
||||||
}, 300);
|
}, 300);
|
||||||
pulling = false;
|
pulling = false;
|
||||||
pullDistance = 0;
|
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
|
||||||
});
|
function isAtTop() {
|
||||||
|
return container.scrollTop <= 0;
|
||||||
|
}
|
||||||
|
function isAtBottom() {
|
||||||
|
return (
|
||||||
|
container.scrollTop + container.clientHeight >= container.scrollHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
(e) => {
|
||||||
|
startY = e.touches[0].clientY;
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
(e) => {
|
||||||
|
const diff = e.touches[0].clientY - startY;
|
||||||
|
const isPullingDown = diff > 0 && isAtTop();
|
||||||
|
const isPullingUp = diff < 0 && isAtBottom();
|
||||||
|
|
||||||
|
if (isPullingDown) {
|
||||||
|
pulling = true;
|
||||||
|
applyPull(Math.min(diff * resistance, maxPull));
|
||||||
|
} else if (isPullingUp) {
|
||||||
|
pulling = true;
|
||||||
|
applyPull(Math.max(diff * resistance, -maxPull));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
"touchend",
|
||||||
|
() => {
|
||||||
|
if (pulling) release();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRubberbanding() {
|
||||||
|
document
|
||||||
|
.querySelectorAll("#chat-scroll-container, .content-scroll-container")
|
||||||
|
.forEach((container) => attachRubberbanding(container));
|
||||||
}
|
}
|
||||||
|
|
||||||
// === INITIALIZATION ===
|
// === INITIALIZATION ===
|
||||||
@@ -171,19 +195,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run on DOMContentLoaded
|
// Run on DOMContentLoaded
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
} else {
|
} else {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-init rubberbanding after HTMX navigations
|
// Re-init rubberbanding after HTMX navigations
|
||||||
document.body.addEventListener('htmx:afterSettle', () => {
|
document.body.addEventListener("htmx:afterSettle", () => {
|
||||||
initRubberbanding();
|
initRubberbanding();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add typewriter cursor blink animation
|
// Add typewriter cursor blink animation
|
||||||
const style = document.createElement('style');
|
const style = document.createElement("style");
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -195,5 +219,4 @@
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Knowledge graph renderer: interactive 2D force graph with
|
// Knowledge graph renderer: interactive 2D force graph with
|
||||||
// zoom/pan, search, neighbor highlighting, curved links with arrows,
|
// zoom/pan, search, neighbor highlighting, curved links with arrows,
|
||||||
// responsive resize, and type/relationship legends.
|
// responsive resize, and type/relationship legends.
|
||||||
(function () {
|
(() => {
|
||||||
const D3_SRC = '/assets/d3.min.js';
|
const D3_SRC = "/assets/d3.min.js";
|
||||||
|
|
||||||
let d3Loading = null;
|
let d3Loading = null;
|
||||||
|
|
||||||
@@ -10,19 +10,39 @@
|
|||||||
if (window.d3) return Promise.resolve();
|
if (window.d3) return Promise.resolve();
|
||||||
if (d3Loading) return d3Loading;
|
if (d3Loading) return d3Loading;
|
||||||
d3Loading = new Promise((resolve, reject) => {
|
d3Loading = new Promise((resolve, reject) => {
|
||||||
const s = document.createElement('script');
|
const s = document.createElement("script");
|
||||||
s.src = D3_SRC;
|
s.src = D3_SRC;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.onload = () => resolve();
|
s.onload = () => resolve();
|
||||||
s.onerror = () => reject(new Error('Failed to load D3'));
|
s.onerror = () => reject(new Error("Failed to load D3"));
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
return d3Loading;
|
return d3Loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple palettes (kept deterministic across renders)
|
// Simple palettes (kept deterministic across renders)
|
||||||
const PALETTE_A = ['#60A5FA', '#34D399', '#F59E0B', '#A78BFA', '#F472B6', '#F87171', '#22D3EE', '#84CC16', '#FB7185'];
|
const PALETTE_A = [
|
||||||
const PALETTE_B = ['#94A3B8', '#A3A3A3', '#9CA3AF', '#C084FC', '#FDA4AF', '#FCA5A5', '#67E8F9', '#A3E635', '#FDBA74'];
|
"#60A5FA",
|
||||||
|
"#34D399",
|
||||||
|
"#F59E0B",
|
||||||
|
"#A78BFA",
|
||||||
|
"#F472B6",
|
||||||
|
"#F87171",
|
||||||
|
"#22D3EE",
|
||||||
|
"#84CC16",
|
||||||
|
"#FB7185",
|
||||||
|
];
|
||||||
|
const PALETTE_B = [
|
||||||
|
"#94A3B8",
|
||||||
|
"#A3A3A3",
|
||||||
|
"#9CA3AF",
|
||||||
|
"#C084FC",
|
||||||
|
"#FDA4AF",
|
||||||
|
"#FCA5A5",
|
||||||
|
"#67E8F9",
|
||||||
|
"#A3E635",
|
||||||
|
"#FDBA74",
|
||||||
|
];
|
||||||
|
|
||||||
function buildMap(values) {
|
function buildMap(values) {
|
||||||
const unique = Array.from(new Set(values.filter(Boolean)));
|
const unique = Array.from(new Set(values.filter(Boolean)));
|
||||||
@@ -40,17 +60,19 @@
|
|||||||
|
|
||||||
function radiusForDegree(deg) {
|
function radiusForDegree(deg) {
|
||||||
const d = Math.max(0, +deg || 0);
|
const d = Math.max(0, +deg || 0);
|
||||||
const r = 6 + Math.sqrt(d) * 3; // gentle growth
|
const r = 6 + Math.sqrt(d) * 3;
|
||||||
return Math.max(6, Math.min(r, 24));
|
return Math.max(6, Math.min(r, 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
function curvedPath(d) {
|
function curvedPath(d) {
|
||||||
const sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
|
const sx = d.source.x,
|
||||||
const dx = tx - sx, dy = ty - sy;
|
sy = d.source.y,
|
||||||
const dr = Math.hypot(dx, dy) * 0.7; // curve radius
|
tx = d.target.x,
|
||||||
|
ty = d.target.y;
|
||||||
|
const dx = tx - sx,
|
||||||
|
dy = ty - sy;
|
||||||
const mx = (sx + tx) / 2;
|
const mx = (sx + tx) / 2;
|
||||||
const my = (sy + ty) / 2;
|
const my = (sy + ty) / 2;
|
||||||
// Offset normal to create a consistent arc
|
|
||||||
const nx = -dy / (Math.hypot(dx, dy) || 1);
|
const nx = -dy / (Math.hypot(dx, dy) || 1);
|
||||||
const ny = dx / (Math.hypot(dx, dy) || 1);
|
const ny = dx / (Math.hypot(dx, dy) || 1);
|
||||||
const cx = mx + nx * 20;
|
const cx = mx + nx * 20;
|
||||||
@@ -59,58 +81,359 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAdjacency(nodes, links) {
|
function buildAdjacency(nodes, links) {
|
||||||
const idToNode = new Map(nodes.map(n => [n.id, n]));
|
const idToNode = new Map(nodes.map((n) => [n.id, n]));
|
||||||
const neighbors = new Map();
|
const neighbors = new Map();
|
||||||
nodes.forEach(n => neighbors.set(n.id, new Set()));
|
nodes.forEach((n) => neighbors.set(n.id, new Set()));
|
||||||
links.forEach(l => {
|
links.forEach((l) => {
|
||||||
const s = typeof l.source === 'object' ? l.source.id : l.source;
|
const s = typeof l.source === "object" ? l.source.id : l.source;
|
||||||
const t = typeof l.target === 'object' ? l.target.id : l.target;
|
const t = typeof l.target === "object" ? l.target.id : l.target;
|
||||||
if (neighbors.has(s)) neighbors.get(s).add(t);
|
if (neighbors.has(s)) neighbors.get(s).add(t);
|
||||||
if (neighbors.has(t)) neighbors.get(t).add(s);
|
if (neighbors.has(t)) neighbors.get(t).add(s);
|
||||||
});
|
});
|
||||||
return { idToNode, neighbors };
|
return { idToNode, neighbors };
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachOverlay(container, { onSearch, onToggleNames, onToggleEdgeLabels, onCenter }) {
|
function buildColorMaps(nodes, links) {
|
||||||
const overlay = document.createElement('div');
|
return {
|
||||||
overlay.className = 'kg-overlay';
|
typeColor: buildMap(nodes.map((n) => n.entity_type)),
|
||||||
|
relColor: linkColorMap(links.map((l) => l.relationship_type)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const primaryRow = document.createElement('div');
|
// ---------------------------------------------------------------------------
|
||||||
primaryRow.className = 'kg-control-row kg-control-row-primary';
|
// Data loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const secondaryRow = document.createElement('div');
|
async function loadGraphData(container) {
|
||||||
secondaryRow.className = 'kg-control-row kg-control-row-secondary';
|
const et = container.dataset.entityType || "";
|
||||||
|
const cc = container.dataset.contentCategory || "";
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (et) qs.set("entity_type", et);
|
||||||
|
if (cc) qs.set("content_category", cc);
|
||||||
|
|
||||||
// search box
|
const url =
|
||||||
const input = document.createElement('input');
|
"/knowledge/graph.json" + (qs.toString() ? "?" + qs.toString() : "");
|
||||||
input.type = 'text';
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
input.placeholder = 'Search nodes…';
|
if (!res.ok) throw new Error("Failed to load graph data");
|
||||||
input.className = 'nb-input kg-search-input';
|
return res.json();
|
||||||
input.addEventListener('keydown', (e) => {
|
}
|
||||||
if (e.key === 'Enter') onSearch && onSearch(input.value.trim());
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG scaffolding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSvg(container, width, height) {
|
||||||
|
const svg = d3
|
||||||
|
.select(container)
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", "100%")
|
||||||
|
.attr("height", height)
|
||||||
|
.attr("viewBox", [0, 0, width, height])
|
||||||
|
.attr(
|
||||||
|
"style",
|
||||||
|
"cursor: grab; touch-action: none; background: transparent;",
|
||||||
|
);
|
||||||
|
return { svg, g: svg.append("g"), defs: svg.append("defs") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArrowMarker(defs, relationshipType, color) {
|
||||||
|
const id = `arrow-${relationshipType.replace(/[^a-z0-9_-]/gi, "_")}`;
|
||||||
|
if (document.getElementById(id)) return id;
|
||||||
|
defs
|
||||||
|
.append("marker")
|
||||||
|
.attr("id", id)
|
||||||
|
.attr("viewBox", "0 -5 10 10")
|
||||||
|
.attr("refX", 16)
|
||||||
|
.attr("refY", 0)
|
||||||
|
.attr("markerWidth", 6)
|
||||||
|
.attr("markerHeight", 6)
|
||||||
|
.attr("orient", "auto")
|
||||||
|
.append("path")
|
||||||
|
.attr("d", "M0,-5L10,0L0,5")
|
||||||
|
.attr("fill", color);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Simulation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createSimulation(nodes, links, width, height) {
|
||||||
|
return d3
|
||||||
|
.forceSimulation(nodes)
|
||||||
|
.force(
|
||||||
|
"link",
|
||||||
|
d3
|
||||||
|
.forceLink(links)
|
||||||
|
.id((d) => d.id)
|
||||||
|
.distance(70)
|
||||||
|
.strength(0.5),
|
||||||
|
)
|
||||||
|
.force("charge", d3.forceManyBody().strength(-220))
|
||||||
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force(
|
||||||
|
"collision",
|
||||||
|
d3.forceCollide().radius((d) => radiusForDegree(d.degree) + 6),
|
||||||
|
)
|
||||||
|
.force("y", d3.forceY(height / 2).strength(0.02))
|
||||||
|
.force("x", d3.forceX(width / 2).strength(0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawing helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function drawLinks(g, links, relColor, markerFor) {
|
||||||
|
return g
|
||||||
|
.append("g")
|
||||||
|
.attr("fill", "none")
|
||||||
|
.attr("stroke-opacity", 0.7)
|
||||||
|
.selectAll("path")
|
||||||
|
.data(links)
|
||||||
|
.join("path")
|
||||||
|
.attr("stroke", (d) => relColor.get(d.relationship_type) || "#CBD5E1")
|
||||||
|
.attr("stroke-width", 1.5)
|
||||||
|
.attr("marker-end", (d) =>
|
||||||
|
markerFor(
|
||||||
|
d.relationship_type || "rel",
|
||||||
|
relColor.get(d.relationship_type) || "#CBD5E1",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLinkLabels(g, links) {
|
||||||
|
return g
|
||||||
|
.append("g")
|
||||||
|
.selectAll("text")
|
||||||
|
.data(links)
|
||||||
|
.join("text")
|
||||||
|
.attr("font-size", 9)
|
||||||
|
.attr("fill", "#475569")
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr("opacity", 0.7)
|
||||||
|
.text((d) => d.relationship_type || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawNodes(
|
||||||
|
g,
|
||||||
|
nodes,
|
||||||
|
typeColor,
|
||||||
|
{ setHighlight, clearHighlight },
|
||||||
|
simulation,
|
||||||
|
) {
|
||||||
|
const node = g
|
||||||
|
.append("g")
|
||||||
|
.attr("stroke", "#fff")
|
||||||
|
.attr("stroke-width", 1.5)
|
||||||
|
.selectAll("circle")
|
||||||
|
.data(nodes)
|
||||||
|
.join("circle")
|
||||||
|
.attr("r", (d) => radiusForDegree(d.degree))
|
||||||
|
.attr("fill", (d) => typeColor.get(d.entity_type) || "#94A3B8")
|
||||||
|
.attr("cursor", "pointer")
|
||||||
|
.on("mouseenter", (_evt, d) => {
|
||||||
|
setHighlight(d);
|
||||||
|
})
|
||||||
|
.on("mouseleave", () => {
|
||||||
|
clearHighlight();
|
||||||
|
})
|
||||||
|
.on("click", function (_evt, d) {
|
||||||
|
if (d.fx == null) {
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
this.setAttribute("data-pinned", "true");
|
||||||
|
} else {
|
||||||
|
d.fx = null;
|
||||||
|
d.fy = null;
|
||||||
|
this.removeAttribute("data-pinned");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.call(
|
||||||
|
d3
|
||||||
|
.drag()
|
||||||
|
.on("start", (event, d) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
d.fx = d.x;
|
||||||
|
d.fy = d.y;
|
||||||
|
})
|
||||||
|
.on("drag", (event, d) => {
|
||||||
|
d.fx = event.x;
|
||||||
|
d.fy = event.y;
|
||||||
|
})
|
||||||
|
.on("end", (event, _d) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
node
|
||||||
|
.append("title")
|
||||||
|
.text((d) => `${d.name} • ${d.entity_type} • deg ${d.degree}`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLabels(g, nodes) {
|
||||||
|
return g
|
||||||
|
.append("g")
|
||||||
|
.selectAll("text")
|
||||||
|
.data(nodes)
|
||||||
|
.join("text")
|
||||||
|
.text((d) => d.name)
|
||||||
|
.attr("font-size", 11)
|
||||||
|
.attr("fill", "#111827")
|
||||||
|
.attr("stroke", "white")
|
||||||
|
.attr("paint-order", "stroke")
|
||||||
|
.attr("stroke-width", 3)
|
||||||
|
.attr("dx", (d) => radiusForDegree(d.degree) + 6)
|
||||||
|
.attr("dy", 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Highlight / search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createHighlighting(neighbors, relColor, markerFor) {
|
||||||
|
let node, label, link, linkLabel;
|
||||||
|
|
||||||
|
function setHighlight(n) {
|
||||||
|
const ns = neighbors.get(n.id) || new Set();
|
||||||
|
node.attr("opacity", (d) => (d.id === n.id || ns.has(d.id) ? 1 : 0.15));
|
||||||
|
label.attr("opacity", (d) => (d.id === n.id || ns.has(d.id) ? 1 : 0.15));
|
||||||
|
link
|
||||||
|
.attr("stroke-opacity", (d) => {
|
||||||
|
const s = typeof d.source === "object" ? d.source.id : d.source;
|
||||||
|
const t = typeof d.target === "object" ? d.target.id : d.target;
|
||||||
|
return s === n.id || t === n.id || (ns.has(s) && ns.has(t))
|
||||||
|
? 0.9
|
||||||
|
: 0.05;
|
||||||
|
})
|
||||||
|
.attr("marker-end", (d) => {
|
||||||
|
const c = relColor.get(d.relationship_type) || "#CBD5E1";
|
||||||
|
return markerFor(d.relationship_type || "rel", c);
|
||||||
|
});
|
||||||
|
linkLabel.attr("opacity", (d) => {
|
||||||
|
const s = typeof d.source === "object" ? d.source.id : d.source;
|
||||||
|
const t = typeof d.target === "object" ? d.target.id : d.target;
|
||||||
|
return s === n.id || t === n.id ? 0.9 : 0.05;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
node.attr("opacity", 1);
|
||||||
|
label.attr("opacity", 1);
|
||||||
|
link.attr("stroke-opacity", 0.7);
|
||||||
|
linkLabel.attr("opacity", 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bind: (_node, _label, _link, _linkLabel) => {
|
||||||
|
node = _node;
|
||||||
|
label = _label;
|
||||||
|
link = _link;
|
||||||
|
linkLabel = _linkLabel;
|
||||||
|
},
|
||||||
|
setHighlight,
|
||||||
|
clearHighlight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zoom
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createZoom(svg, g) {
|
||||||
|
const zoom = d3
|
||||||
|
.zoom()
|
||||||
|
.scaleExtent([0.25, 5])
|
||||||
|
.on("zoom", (event) => {
|
||||||
|
g.attr("transform", event.transform);
|
||||||
|
});
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
function zoomTo(k, center) {
|
||||||
|
const transform = d3.zoomIdentity
|
||||||
|
.translate(center[0] - k * center[0], center[1] - k * center[1])
|
||||||
|
.scale(k);
|
||||||
|
svg.transition().duration(250).call(zoom.transform, transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { zoom, zoomTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resize
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function attachResize(
|
||||||
|
container,
|
||||||
|
svg,
|
||||||
|
simulation,
|
||||||
|
fallbackWidth,
|
||||||
|
fallbackHeight,
|
||||||
|
) {
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = container.clientWidth || fallbackWidth;
|
||||||
|
const h = container.clientHeight || fallbackHeight;
|
||||||
|
svg.attr("viewBox", [0, 0, w, h]).attr("height", h);
|
||||||
|
simulation.force("center", d3.forceCenter(w / 2, h / 2));
|
||||||
|
simulation.alpha(0.3).restart();
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Overlay controls
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function attachOverlay(
|
||||||
|
container,
|
||||||
|
{ onSearch, onToggleNames, onToggleEdgeLabels, onCenter },
|
||||||
|
) {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "kg-overlay";
|
||||||
|
|
||||||
|
const primaryRow = document.createElement("div");
|
||||||
|
primaryRow.className = "kg-control-row kg-control-row-primary";
|
||||||
|
|
||||||
|
const secondaryRow = document.createElement("div");
|
||||||
|
secondaryRow.className = "kg-control-row kg-control-row-secondary";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.placeholder = "Search nodes…";
|
||||||
|
input.className = "nb-input kg-search-input";
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") onSearch && onSearch(input.value.trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchBtn = document.createElement('button');
|
const searchBtn = document.createElement("button");
|
||||||
searchBtn.className = 'nb-btn btn-xs nb-cta kg-search-btn';
|
searchBtn.className = "nb-btn btn-xs nb-cta kg-search-btn";
|
||||||
searchBtn.textContent = 'Go';
|
searchBtn.textContent = "Go";
|
||||||
searchBtn.addEventListener('click', () => onSearch && onSearch(input.value.trim()));
|
searchBtn.addEventListener(
|
||||||
|
"click",
|
||||||
|
() => onSearch && onSearch(input.value.trim()),
|
||||||
|
);
|
||||||
|
|
||||||
const namesToggle = document.createElement('button');
|
const namesToggle = document.createElement("button");
|
||||||
namesToggle.className = 'nb-btn btn-xs kg-toggle';
|
namesToggle.className = "nb-btn btn-xs kg-toggle";
|
||||||
namesToggle.type = 'button';
|
namesToggle.type = "button";
|
||||||
namesToggle.textContent = 'Names';
|
namesToggle.textContent = "Names";
|
||||||
namesToggle.addEventListener('click', () => onToggleNames && onToggleNames());
|
namesToggle.addEventListener(
|
||||||
|
"click",
|
||||||
|
() => onToggleNames && onToggleNames(),
|
||||||
|
);
|
||||||
|
|
||||||
const labelToggle = document.createElement('button');
|
const labelToggle = document.createElement("button");
|
||||||
labelToggle.className = 'nb-btn btn-xs kg-toggle';
|
labelToggle.className = "nb-btn btn-xs kg-toggle";
|
||||||
labelToggle.type = 'button';
|
labelToggle.type = "button";
|
||||||
labelToggle.textContent = 'Labels';
|
labelToggle.textContent = "Labels";
|
||||||
labelToggle.addEventListener('click', () => onToggleEdgeLabels && onToggleEdgeLabels());
|
labelToggle.addEventListener(
|
||||||
|
"click",
|
||||||
|
() => onToggleEdgeLabels && onToggleEdgeLabels(),
|
||||||
|
);
|
||||||
|
|
||||||
const centerBtn = document.createElement('button');
|
const centerBtn = document.createElement("button");
|
||||||
centerBtn.className = 'nb-btn btn-xs';
|
centerBtn.className = "nb-btn btn-xs";
|
||||||
centerBtn.textContent = 'Center';
|
centerBtn.textContent = "Center";
|
||||||
centerBtn.addEventListener('click', () => onCenter && onCenter());
|
centerBtn.addEventListener("click", () => onCenter && onCenter());
|
||||||
|
|
||||||
primaryRow.appendChild(input);
|
primaryRow.appendChild(input);
|
||||||
primaryRow.appendChild(searchBtn);
|
primaryRow.appendChild(searchBtn);
|
||||||
@@ -122,308 +445,193 @@
|
|||||||
overlay.appendChild(primaryRow);
|
overlay.appendChild(primaryRow);
|
||||||
overlay.appendChild(secondaryRow);
|
overlay.appendChild(secondaryRow);
|
||||||
|
|
||||||
container.style.position = 'relative';
|
container.style.position = "relative";
|
||||||
container.appendChild(overlay);
|
container.appendChild(overlay);
|
||||||
|
|
||||||
return { input, overlay, namesToggle, labelToggle };
|
return { input, overlay, namesToggle, labelToggle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legends
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function attachLegends(container, typeColor, relColor) {
|
function attachLegends(container, typeColor, relColor) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement("div");
|
||||||
wrap.className = 'kg-legend';
|
wrap.className = "kg-legend";
|
||||||
|
|
||||||
function section(title, items) {
|
function section(title, items) {
|
||||||
const sec = document.createElement('div');
|
const sec = document.createElement("div");
|
||||||
sec.className = 'nb-card kg-legend-card';
|
sec.className = "nb-card kg-legend-card";
|
||||||
const h = document.createElement('div'); h.className = 'kg-legend-heading'; h.textContent = title; sec.appendChild(h);
|
const h = document.createElement("div");
|
||||||
|
h.className = "kg-legend-heading";
|
||||||
|
h.textContent = title;
|
||||||
|
sec.appendChild(h);
|
||||||
items.forEach(([label, color]) => {
|
items.forEach(([label, color]) => {
|
||||||
const row = document.createElement('div'); row.className = 'kg-legend-row';
|
const row = document.createElement("div");
|
||||||
const sw = document.createElement('span'); sw.style.background = color; sw.style.width = '12px'; sw.style.height = '12px'; sw.style.border = '2px solid #000';
|
row.className = "kg-legend-row";
|
||||||
const t = document.createElement('span'); t.textContent = label || '—';
|
const sw = document.createElement("span");
|
||||||
row.appendChild(sw); row.appendChild(t); sec.appendChild(row);
|
sw.style.background = color;
|
||||||
|
sw.style.width = "12px";
|
||||||
|
sw.style.height = "12px";
|
||||||
|
sw.style.border = "2px solid #000";
|
||||||
|
const t = document.createElement("span");
|
||||||
|
t.textContent = label || "—";
|
||||||
|
row.appendChild(sw);
|
||||||
|
row.appendChild(t);
|
||||||
|
sec.appendChild(row);
|
||||||
});
|
});
|
||||||
return sec;
|
return sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeItems = Array.from(typeColor.entries());
|
const typeItems = Array.from(typeColor.entries());
|
||||||
if (typeItems.length) wrap.appendChild(section('Entity Type', typeItems));
|
if (typeItems.length) wrap.appendChild(section("Entity Type", typeItems));
|
||||||
const relItems = Array.from(relColor.entries());
|
const relItems = Array.from(relColor.entries());
|
||||||
if (relItems.length) wrap.appendChild(section('Relationship', relItems));
|
if (relItems.length) wrap.appendChild(section("Relationship", relItems));
|
||||||
|
|
||||||
container.appendChild(wrap);
|
container.appendChild(wrap);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main orchestrator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function renderKnowledgeGraph(root) {
|
async function renderKnowledgeGraph(root) {
|
||||||
const container = (root || document).querySelector('#knowledge-graph');
|
const container = (root || document).querySelector("#knowledge-graph");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
await ensureD3().catch(() => {
|
await ensureD3().catch(() => {
|
||||||
const err = document.createElement('div');
|
const err = document.createElement("div");
|
||||||
err.className = 'alert alert-error';
|
err.className = "alert alert-error";
|
||||||
err.textContent = 'Unable to load graph library (D3).';
|
err.textContent = "Unable to load graph library (D3).";
|
||||||
container.appendChild(err);
|
container.appendChild(err);
|
||||||
});
|
});
|
||||||
if (!window.d3) return;
|
if (!window.d3) return;
|
||||||
|
|
||||||
// Clear previous render
|
container.replaceChildren();
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
const width = container.clientWidth || 800;
|
const width = container.clientWidth || 800;
|
||||||
const height = container.clientHeight || 600;
|
const height = container.clientHeight || 600;
|
||||||
|
|
||||||
const et = container.dataset.entityType || '';
|
// 1. Load data
|
||||||
const cc = container.dataset.contentCategory || '';
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (et) qs.set('entity_type', et);
|
|
||||||
if (cc) qs.set('content_category', cc);
|
|
||||||
|
|
||||||
const url = '/knowledge/graph.json' + (qs.toString() ? ('?' + qs.toString()) : '');
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
data = await loadGraphData(container);
|
||||||
if (!res.ok) throw new Error('Failed to load graph data');
|
|
||||||
data = await res.json();
|
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
const err = document.createElement('div');
|
const err = document.createElement("div");
|
||||||
err.className = 'alert alert-error';
|
err.className = "alert alert-error";
|
||||||
err.textContent = 'Unable to load graph data.';
|
err.textContent = "Unable to load graph data.";
|
||||||
container.appendChild(err);
|
container.appendChild(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color maps
|
// 2. Color maps + adjacency
|
||||||
const typeColor = buildMap(data.nodes.map(n => n.entity_type));
|
const { typeColor, relColor } = buildColorMaps(data.nodes, data.links);
|
||||||
const relColor = linkColorMap(data.links.map(l => l.relationship_type));
|
|
||||||
const { neighbors } = buildAdjacency(data.nodes, data.links);
|
const { neighbors } = buildAdjacency(data.nodes, data.links);
|
||||||
|
|
||||||
// Build overlay controls
|
// 3. Build SVG scaffolding
|
||||||
let namesVisible = true;
|
const { svg, g, defs } = buildSvg(container, width, height);
|
||||||
let edgeLabelsVisible = true;
|
|
||||||
|
|
||||||
const togglePressedState = (button, state) => {
|
// 4. Arrow marker factory
|
||||||
if (!button) return;
|
const markerFor = (rel, color) =>
|
||||||
button.setAttribute('aria-pressed', state ? 'true' : 'false');
|
`url(#${createArrowMarker(defs, rel, color)})`;
|
||||||
button.classList.toggle('kg-toggle-active', !!state);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { input, namesToggle, labelToggle } = attachOverlay(container, {
|
// 5. Simulation
|
||||||
onSearch: (q) => focusSearch(q),
|
const simulation = createSimulation(data.nodes, data.links, width, height);
|
||||||
onToggleNames: () => {
|
|
||||||
namesVisible = !namesVisible;
|
|
||||||
label.style('display', namesVisible ? null : 'none');
|
|
||||||
togglePressedState(namesToggle, namesVisible);
|
|
||||||
},
|
|
||||||
onToggleEdgeLabels: () => {
|
|
||||||
edgeLabelsVisible = !edgeLabelsVisible;
|
|
||||||
linkLabel.style('display', edgeLabelsVisible ? null : 'none');
|
|
||||||
togglePressedState(labelToggle, edgeLabelsVisible);
|
|
||||||
},
|
|
||||||
onCenter: () => zoomTo(1, [width / 2, height / 2])
|
|
||||||
});
|
|
||||||
|
|
||||||
togglePressedState(namesToggle, namesVisible);
|
// 6. Highlighting (created before drawing so callbacks exist)
|
||||||
togglePressedState(labelToggle, edgeLabelsVisible);
|
const highlighting = createHighlighting(neighbors, relColor, markerFor);
|
||||||
|
|
||||||
// SVG + zoom
|
// 7. Draw elements
|
||||||
const svg = d3.select(container)
|
const link = drawLinks(g, data.links, relColor, markerFor);
|
||||||
.append('svg')
|
const linkLabel = drawLinkLabels(g, data.links);
|
||||||
.attr('width', '100%')
|
const node = drawNodes(g, data.nodes, typeColor, highlighting, simulation);
|
||||||
.attr('height', height)
|
const label = drawLabels(g, data.nodes);
|
||||||
.attr('viewBox', [0, 0, width, height])
|
highlighting.bind(node, label, link, linkLabel);
|
||||||
.attr('style', 'cursor: grab; touch-action: none; background: transparent;')
|
|
||||||
.call(d3.zoom().scaleExtent([0.25, 5]).on('zoom', (event) => {
|
|
||||||
g.attr('transform', event.transform);
|
|
||||||
}));
|
|
||||||
|
|
||||||
const g = svg.append('g');
|
// 8. Zoom (single instance)
|
||||||
|
const { zoom, zoomTo } = createZoom(svg, g);
|
||||||
|
|
||||||
// Defs for arrows
|
// 9. Search helper
|
||||||
const defs = svg.append('defs');
|
|
||||||
const markerFor = (key, color) => {
|
|
||||||
const id = `arrow-${key.replace(/[^a-z0-9_-]/gi, '_')}`;
|
|
||||||
if (!document.getElementById(id)) {
|
|
||||||
defs.append('marker')
|
|
||||||
.attr('id', id)
|
|
||||||
.attr('viewBox', '0 -5 10 10')
|
|
||||||
.attr('refX', 16)
|
|
||||||
.attr('refY', 0)
|
|
||||||
.attr('markerWidth', 6)
|
|
||||||
.attr('markerHeight', 6)
|
|
||||||
.attr('orient', 'auto')
|
|
||||||
.append('path')
|
|
||||||
.attr('d', 'M0,-5L10,0L0,5')
|
|
||||||
.attr('fill', color);
|
|
||||||
}
|
|
||||||
return `url(#${id})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Forces
|
|
||||||
const linkForce = d3.forceLink(data.links)
|
|
||||||
.id(d => d.id)
|
|
||||||
.distance(l => 70)
|
|
||||||
.strength(0.5);
|
|
||||||
|
|
||||||
const simulation = d3.forceSimulation(data.nodes)
|
|
||||||
.force('link', linkForce)
|
|
||||||
.force('charge', d3.forceManyBody().strength(-220))
|
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
||||||
.force('collision', d3.forceCollide().radius(d => radiusForDegree(d.degree) + 6))
|
|
||||||
.force('y', d3.forceY(height / 2).strength(0.02))
|
|
||||||
.force('x', d3.forceX(width / 2).strength(0.02));
|
|
||||||
|
|
||||||
// Links as paths so we can curve + arrow
|
|
||||||
const link = g.append('g')
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke-opacity', 0.7)
|
|
||||||
.selectAll('path')
|
|
||||||
.data(data.links)
|
|
||||||
.join('path')
|
|
||||||
.attr('stroke', d => relColor.get(d.relationship_type) || '#CBD5E1')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('marker-end', d => markerFor(d.relationship_type || 'rel', relColor.get(d.relationship_type) || '#CBD5E1'));
|
|
||||||
|
|
||||||
// Optional edge labels (midpoint)
|
|
||||||
const linkLabel = g.append('g')
|
|
||||||
.selectAll('text')
|
|
||||||
.data(data.links)
|
|
||||||
.join('text')
|
|
||||||
.attr('font-size', 9)
|
|
||||||
.attr('fill', '#475569')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('opacity', 0.7)
|
|
||||||
.text(d => d.relationship_type || '');
|
|
||||||
|
|
||||||
// Nodes
|
|
||||||
const node = g.append('g')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.selectAll('circle')
|
|
||||||
.data(data.nodes)
|
|
||||||
.join('circle')
|
|
||||||
.attr('r', d => radiusForDegree(d.degree))
|
|
||||||
.attr('fill', d => typeColor.get(d.entity_type) || '#94A3B8')
|
|
||||||
.attr('cursor', 'pointer')
|
|
||||||
.on('mouseenter', function (_evt, d) { setHighlight(d); })
|
|
||||||
.on('mouseleave', function () { clearHighlight(); })
|
|
||||||
.on('click', function (_evt, d) {
|
|
||||||
// pin/unpin on click
|
|
||||||
if (d.fx == null) { d.fx = d.x; d.fy = d.y; this.setAttribute('data-pinned', 'true'); }
|
|
||||||
else { d.fx = null; d.fy = null; this.removeAttribute('data-pinned'); }
|
|
||||||
})
|
|
||||||
.call(d3.drag()
|
|
||||||
.on('start', (event, d) => {
|
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
||||||
d.fx = d.x; d.fy = d.y;
|
|
||||||
})
|
|
||||||
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
|
||||||
.on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); }));
|
|
||||||
|
|
||||||
node.append('title').text(d => `${d.name} • ${d.entity_type} • deg ${d.degree}`);
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
const label = g.append('g')
|
|
||||||
.selectAll('text')
|
|
||||||
.data(data.nodes)
|
|
||||||
.join('text')
|
|
||||||
.text(d => d.name)
|
|
||||||
.attr('font-size', 11)
|
|
||||||
.attr('fill', '#111827')
|
|
||||||
.attr('stroke', 'white')
|
|
||||||
.attr('paint-order', 'stroke')
|
|
||||||
.attr('stroke-width', 3)
|
|
||||||
.attr('dx', d => radiusForDegree(d.degree) + 6)
|
|
||||||
.attr('dy', 4);
|
|
||||||
|
|
||||||
// Legends
|
|
||||||
attachLegends(container, typeColor, relColor);
|
|
||||||
|
|
||||||
// Highlight logic
|
|
||||||
function setHighlight(n) {
|
|
||||||
const ns = neighbors.get(n.id) || new Set();
|
|
||||||
node.attr('opacity', d => (d.id === n.id || ns.has(d.id)) ? 1 : 0.15);
|
|
||||||
label.attr('opacity', d => (d.id === n.id || ns.has(d.id)) ? 1 : 0.15);
|
|
||||||
link
|
|
||||||
.attr('stroke-opacity', d => {
|
|
||||||
const s = (typeof d.source === 'object') ? d.source.id : d.source;
|
|
||||||
const t = (typeof d.target === 'object') ? d.target.id : d.target;
|
|
||||||
return (s === n.id || t === n.id || (ns.has(s) && ns.has(t))) ? 0.9 : 0.05;
|
|
||||||
})
|
|
||||||
.attr('marker-end', d => {
|
|
||||||
const c = relColor.get(d.relationship_type) || '#CBD5E1';
|
|
||||||
return markerFor(d.relationship_type || 'rel', c);
|
|
||||||
});
|
|
||||||
linkLabel.attr('opacity', d => {
|
|
||||||
const s = (typeof d.source === 'object') ? d.source.id : d.source;
|
|
||||||
const t = (typeof d.target === 'object') ? d.target.id : d.target;
|
|
||||||
return (s === n.id || t === n.id) ? 0.9 : 0.05;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function clearHighlight() {
|
|
||||||
node.attr('opacity', 1);
|
|
||||||
label.attr('opacity', 1);
|
|
||||||
link.attr('stroke-opacity', 0.7);
|
|
||||||
linkLabel.attr('opacity', 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search + center helpers
|
|
||||||
function centerOnNode(n) {
|
function centerOnNode(n) {
|
||||||
const k = 1.5; // zoom factor
|
const k = 1.5;
|
||||||
const x = n.x, y = n.y;
|
// recenter: svg dimensions / 2 minus k * node position
|
||||||
const transform = d3.zoomIdentity.translate(width / 2 - k * x, height / 2 - k * y).scale(k);
|
const transform = d3.zoomIdentity
|
||||||
|
.translate(width / 2 - k * n.x, height / 2 - k * n.y)
|
||||||
|
.scale(k);
|
||||||
svg.transition().duration(350).call(zoom.transform, transform);
|
svg.transition().duration(350).call(zoom.transform, transform);
|
||||||
}
|
}
|
||||||
function focusSearch(query) {
|
function focusSearch(query) {
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
const found = data.nodes.find(n => (n.name || '').toLowerCase().includes(q));
|
const found = data.nodes.find((n) =>
|
||||||
if (found) { setHighlight(found); centerOnNode(found); }
|
(n.name || "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
highlighting.setHighlight(found);
|
||||||
|
centerOnNode(found);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose zoom instance
|
// 10. Overlay controls
|
||||||
const zoom = d3.zoom().scaleExtent([0.25, 5]).on('zoom', (event) => g.attr('transform', event.transform));
|
let namesVisible = true;
|
||||||
svg.call(zoom);
|
let edgeLabelsVisible = true;
|
||||||
function zoomTo(k, center) {
|
const togglePressedState = (button, state) => {
|
||||||
const transform = d3.zoomIdentity.translate(width / 2 - k * center[0], height / 2 - k * center[1]).scale(k);
|
if (!button) return;
|
||||||
svg.transition().duration(250).call(zoom.transform, transform);
|
button.setAttribute("aria-pressed", state ? "true" : "false");
|
||||||
}
|
button.classList.toggle("kg-toggle-active", !!state);
|
||||||
|
};
|
||||||
|
|
||||||
// Tick update
|
const { namesToggle, labelToggle } = attachOverlay(container, {
|
||||||
simulation.on('tick', () => {
|
onSearch: (q) => focusSearch(q),
|
||||||
link.attr('d', curvedPath);
|
onToggleNames: () => {
|
||||||
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
namesVisible = !namesVisible;
|
||||||
label.attr('x', d => d.x).attr('y', d => d.y);
|
label.style("display", namesVisible ? null : "none");
|
||||||
linkLabel.attr('x', d => (d.source.x + d.target.x) / 2).attr('y', d => (d.source.y + d.target.y) / 2);
|
togglePressedState(namesToggle, namesVisible);
|
||||||
|
},
|
||||||
|
onToggleEdgeLabels: () => {
|
||||||
|
edgeLabelsVisible = !edgeLabelsVisible;
|
||||||
|
linkLabel.style("display", edgeLabelsVisible ? null : "none");
|
||||||
|
togglePressedState(labelToggle, edgeLabelsVisible);
|
||||||
|
},
|
||||||
|
onCenter: () => zoomTo(1, [width / 2, height / 2]),
|
||||||
|
});
|
||||||
|
togglePressedState(namesToggle, namesVisible);
|
||||||
|
togglePressedState(labelToggle, edgeLabelsVisible);
|
||||||
|
|
||||||
|
// 11. Tick update
|
||||||
|
simulation.on("tick", () => {
|
||||||
|
link.attr("d", curvedPath);
|
||||||
|
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
|
||||||
|
label.attr("x", (d) => d.x).attr("y", (d) => d.y);
|
||||||
|
linkLabel
|
||||||
|
.attr("x", (d) => (d.source.x + d.target.x) / 2)
|
||||||
|
.attr("y", (d) => (d.source.y + d.target.y) / 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize handling
|
// 12. Legends + resize
|
||||||
const ro = new ResizeObserver(() => {
|
attachLegends(container, typeColor, relColor);
|
||||||
const w = container.clientWidth || width;
|
attachResize(container, svg, simulation, width, height);
|
||||||
const h = container.clientHeight || height;
|
|
||||||
svg.attr('viewBox', [0, 0, w, h]).attr('height', h);
|
|
||||||
simulation.force('center', d3.forceCenter(w / 2, h / 2));
|
|
||||||
simulation.alpha(0.3).restart();
|
|
||||||
});
|
|
||||||
ro.observe(container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function tryRender(root) {
|
function tryRender(root) {
|
||||||
const container = (root || document).querySelector('#knowledge-graph');
|
const container = (root || document).querySelector("#knowledge-graph");
|
||||||
if (container) renderKnowledgeGraph(root);
|
if (container) renderKnowledgeGraph(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for debugging/manual re-render
|
|
||||||
window.renderKnowledgeGraph = () => renderKnowledgeGraph(document);
|
window.renderKnowledgeGraph = () => renderKnowledgeGraph(document);
|
||||||
|
|
||||||
// Full page load
|
document.addEventListener("DOMContentLoaded", () => tryRender(document));
|
||||||
document.addEventListener('DOMContentLoaded', () => tryRender(document));
|
|
||||||
|
|
||||||
// HTMX partial swaps
|
document.body.addEventListener("knowledge-graph-refresh", () => {
|
||||||
document.body.addEventListener('knowledge-graph-refresh', () => {
|
|
||||||
tryRender(document);
|
tryRender(document);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSettle', (evt) => {
|
document.body.addEventListener("htmx:afterSettle", (evt) => {
|
||||||
tryRender(evt && evt.target ? evt.target : document);
|
tryRender(evt && evt.target ? evt.target : document);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user