Files
Nexus.Reader/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js
T
Antigravity f18663426b style(ui): refactor reader layout grid, fix focus mode layout collapse, fix SVG rendering dots, reorganize intelligence toolbar (#69)
Reorganized the reader toolbar and layout grid to improve visual consistency and layout robustness in Focus Mode. Fixed outline SVG rendering bugs that caused icons to show as solid dots.

Closes #70

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #69
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-05 09:51:29 +00:00

670 lines
23 KiB
JavaScript

import * as d3 from 'https://esm.sh/d3@7';
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
const getNodeType = d => {
if (d) {
if (d.type) {
const t = d.type.toLowerCase();
if (t === 'definition') return 'definition';
if (t === 'table') return 'table';
if (t === 'rule') return 'rule';
if (t === 'section') return 'section';
}
if (d.group) {
const g = d.group.toLowerCase();
if (g === 'definition') return 'definition';
if (g === 'table') return 'table';
if (g === 'rule') return 'rule';
if (g === 'section') return 'section';
}
}
return null;
};
const getNodeGroup = d => {
if (d && d.group) {
const g = d.group.toLowerCase();
if (g === 'bridge') return 'bridge';
if (g === 'current') return 'current';
if (g === 'concept') return 'concept';
}
return 'concept'; // fallback
};
const getCategoryStyle = d => {
const type = getNodeType(d);
const group = getNodeGroup(d);
// 1. Rule (red/coral)
if (type === 'rule') {
return {
color: 'var(--nexus-node-rule, #ff4646)',
fill: 'var(--nexus-node-rule-bg, rgba(255, 70, 70, 0.1))',
opacity: 0.8,
glowKey: 'rule',
textColor: 'var(--nexus-node-rule-text, #ff8b8b)'
};
}
// 2. Definition (gold/amber)
if (type === 'definition') {
return {
color: 'var(--nexus-node-definition, #ffb03a)',
fill: 'var(--nexus-node-definition-bg, rgba(255, 176, 58, 0.1))',
opacity: 0.8,
glowKey: 'definition',
textColor: 'var(--nexus-node-definition-text, #ffd18c)'
};
}
// 3. Table (purple/magenta)
if (type === 'table') {
return {
color: 'var(--nexus-node-table, #d946ef)',
fill: 'var(--nexus-node-table-bg, rgba(217, 70, 239, 0.1))',
opacity: 0.8,
glowKey: 'table',
textColor: 'var(--nexus-node-table-text, #f5d0fe)'
};
}
// 4. Section (blue/indigo)
if (type === 'section') {
return {
color: 'var(--nexus-node-section, #3b82f6)',
fill: 'var(--nexus-node-section-bg, rgba(59, 130, 246, 0.1))',
opacity: 0.8,
glowKey: 'section',
textColor: 'var(--nexus-node-section-text, #93c5fd)'
};
}
// 5. Bridge (cyan/comparison)
if (group === 'bridge') {
return {
color: 'var(--nexus-node-bridge, #06b6d4)',
fill: 'var(--nexus-node-bridge-bg, rgba(6, 182, 212, 0.1))',
opacity: 0.7,
glowKey: 'bridge',
textColor: 'var(--nexus-node-bridge-text, #67e8f9)'
};
}
// 6. Current (active/focus landmark - neon green)
if (group === 'current') {
return {
color: 'var(--nexus-node-current, var(--nexus-neon))',
fill: 'var(--nexus-node-current-bg, rgba(0, 255, 153, 0.15))',
opacity: 0.9,
glowKey: 'current',
textColor: 'var(--nexus-node-current-text, #ffffff)'
};
}
// 7. Concept / Default (subtle cool steel blue/teal)
return {
color: 'var(--nexus-node-concept, #00d2c4)',
fill: 'var(--nexus-node-concept-bg, rgba(0, 210, 196, 0.05))',
opacity: 0.4,
glowKey: 'concept',
textColor: 'var(--nexus-node-concept-text, #e0e0e0)'
};
};
let simulation;
let zoomBehavior;
let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
let isMobileMode = false;
let activeNodeId = null;
const getNodeGlyph = d => {
if (!d) return 'C';
const type = getNodeType(d);
const group = getNodeGroup(d);
if (type === 'rule') return '§';
if (type === 'definition') return 'D';
if (type === 'table') return 'T';
if (type === 'section') return 'S';
if (group === 'bridge') return 'B';
if (group === 'current') return '★';
return 'C';
};
function updateNodeAppearances() {
if (!node) return;
node.each(function (d) {
const g = d3.select(this);
const rect = g.select(".node-pill");
const text = g.select("text");
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
const showFull = !isMobileMode || isSelected || isCurrent;
if (showFull) {
rect.transition().duration(250)
.attr("x", -getPillWidth(d) / 2)
.attr("width", getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getDisplayLabel(d))
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
} else {
rect.transition().duration(250)
.attr("x", -15)
.attr("width", 30)
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getNodeGlyph(d))
.attr("font-size", "0.9rem")
.attr("font-weight", "bold");
}
});
}
export function setMobileMode(isMobile) {
isMobileMode = isMobile;
if (!simulation) return;
if (isMobile) {
simulation.force("charge", d3.forceManyBody().strength(-60));
simulation.force("link").distance(180);
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
} else {
simulation.force("charge", d3.forceManyBody().strength(-400));
simulation.force("link").distance(120);
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
}
updateNodeAppearances();
simulation.alpha(0.3).restart();
}
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
if (!container) return;
currentDotNetHelper = dotNetHelper;
width = container.clientWidth || 400;
height = container.clientHeight || 400;
// Clean up any existing SVG to prevent duplicates
container.querySelectorAll("svg").forEach(el => el.remove());
// Create SVG
svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", "100%")
.style("background", "var(--nexus-graph-bg, radial-gradient(circle, #1a1a1a 0%, #121212 100%))");
// Radial gradients for Nebula effects
const defs = svgElement.append("defs");
// Fallback radial gradient for legacy nebulaGlow
const radialGradient = defs.append("radialGradient")
.attr("id", "nebulaGlow")
.attr("cx", "50%")
.attr("cy", "50%")
.attr("r", "50%");
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
const colors = {
'rule': 'var(--nexus-node-rule, #ff4646)',
'definition': 'var(--nexus-node-definition, #ffb03a)',
'table': 'var(--nexus-node-table, #d946ef)',
'section': 'var(--nexus-node-section, #3b82f6)',
'bridge': 'var(--nexus-node-bridge, #06b6d4)',
'current': 'var(--nexus-node-current, var(--nexus-neon))',
'concept': 'var(--nexus-node-concept, #00d2c4)'
};
Object.entries(colors).forEach(([key, color]) => {
const radGrad = defs.append("radialGradient")
.attr("id", `nebulaGlow-${key}`)
.attr("cx", "50%")
.attr("cy", "50%")
.attr("r", "50%");
radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
});
// Root Group for Zoom
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
// Container groups for links and nodes to keep order (links below nodes)
rootGroup.append("g").attr("class", "links-layer");
rootGroup.append("g").attr("class", "nodes-layer");
// Badge Element (TU JESTEŚ)
badge = rootGroup.append("g")
.attr("class", "active-badge")
.style("display", "none")
.style("pointer-events", "none");
badge.append("rect")
.attr("x", -35)
.attr("y", -35)
.attr("width", 70)
.attr("height", 20)
.attr("rx", 10)
.attr("fill", "var(--nexus-neon)");
badge.append("text")
.text("TU JESTEŚ")
.attr("text-anchor", "middle")
.attr("y", -21)
.attr("fill", "#000")
.attr("font-weight", "bold")
.attr("font-size", "0.6rem");
// Attach Zoom Behavior
zoomBehavior = d3.zoom()
.scaleExtent([0.3, 4])
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
svgElement.call(zoomBehavior).on("wheel.zoom", null);
// Use ResizeObserver for more reliable container size tracking
resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
handleResize(containerId);
}
});
resizeObserver.observe(container);
isMobileMode = window.innerWidth < 768;
simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
.force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(d => {
if (isMobileMode) {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) return (getPillWidth(d) / 2) + 15;
return 20;
}
return (getPillWidth(d) / 2) + 20;
}));
simulation.on("tick", () => {
if (link) {
link.attr("d", d => {
const dx = d.target.x - d.source.x;
const dy = d.target.y - d.source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
});
}
if (node) {
node.attr("transform", d => {
if (d.x === undefined || isNaN(d.x) || !isFinite(d.x)) d.x = width / 2;
if (d.y === undefined || isNaN(d.y) || !isFinite(d.y)) d.y = height / 2;
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
d.x = Math.max(halfWidth + 20, Math.min(width - halfWidth - 20, d.x));
d.y = Math.max(35, Math.min(height - 35, d.y));
return `translate(${d.x},${d.y})`;
});
}
if (badge && badge.style("display") !== "none") {
const activeData = badge.datum();
if (activeData) {
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
}
}
});
updateData(data);
}
export function updateData(data) {
if (!simulation || !rootGroup) return;
if (!data || !data.nodes) {
clear();
return;
}
// Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
data.nodes.forEach(d => {
if (d.x !== undefined && (!isFinite(d.x) || isNaN(d.x))) d.x = undefined;
if (d.y !== undefined && (!isFinite(d.y) || isNaN(d.y))) d.y = undefined;
if (oldNodes.has(d.id)) {
const old = oldNodes.get(d.id);
if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
d.vx = old.vx;
d.vy = old.vy;
}
});
// Sanitize links to filter out any references to non-existent nodes
const nodeIds = new Set(data.nodes.map(n => n.id));
const validLinks = (data.links || []).filter(l => {
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
return nodeIds.has(srcId) && nodeIds.has(tgtId);
});
// Update Links
link = rootGroup.select(".links-layer")
.selectAll("path")
.data(validLinks, d => {
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
return srcId + "-" + tgtId + "-" + d.type;
})
.join(
enter => enter.append("path")
.attr("stroke", d => {
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
if (d.type === 'Next' || d.type === 'relates_to') return 'var(--nexus-graph-link-secondary, rgba(255,255,255,0.2))';
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
return 'var(--nexus-graph-link-default, rgba(255,255,255,0.1))';
})
.attr("fill", "none")
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
.attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
.style("opacity", 0)
.call(enter => enter.transition().duration(500).style("opacity", 1)),
update => update,
exit => exit.transition().duration(500).style("opacity", 0).remove()
);
// Update Nodes
node = rootGroup.select(".nodes-layer")
.selectAll("g.node-group")
.data(data.nodes, d => d.id)
.join(
enter => {
const g = enter.append("g")
.attr("class", "node-group neon-flash-node")
.style("cursor", "pointer")
.style("opacity", 0)
.on("click", (e, d) => {
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
setActiveNode(d.id);
})
.call(drag(simulation));
g.append("circle")
.attr("r", 30)
.attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
.attr("opacity", d => getCategoryStyle(d).opacity);
g.append("rect")
.attr("class", "node-pill")
.attr("fill", "var(--nexus-node-pill-bg, rgba(20, 20, 20, 0.95))")
.attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.attr("text-anchor", "middle")
.attr("y", 5)
.attr("fill", d => getCategoryStyle(d).textColor);
g.append("title")
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
g.transition().duration(500).style("opacity", 1);
return g;
},
update => update.classed("neon-flash-node", false),
exit => exit.transition().duration(500).style("opacity", 0).remove()
);
updateNodeAppearances();
simulation.nodes(data.nodes);
simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
setTimeout(zoomToFit, 100);
}
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
export function setActiveNode(nodeId) {
if (!svgElement || !node) return;
activeNodeId = nodeId;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) {
dimNodes(null);
badge.style("display", "none");
return;
}
const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
// Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
firstMatch.select(".node-pill").classed("nexus-node-active", true);
// Position badge
badge.style("display", "block").datum(d);
badge.attr("transform", `translate(${d.x},${d.y})`);
// Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId);
// Dynamic collision update if in mobile mode to expand active node
if (isMobileMode && simulation) {
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
}
updateNodeAppearances();
// Smooth transition to the first matching node
svgElement.transition().duration(1000).call(
zoomBehavior.transform,
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
);
}
export function dimNodes(activeNodeId) {
if (!node) return;
node.transition().duration(500)
.style("opacity", d => (activeNodeId === null || d.id === activeNodeId) ? 1 : 0.4);
if (link) {
link.transition().duration(500)
.style("opacity", d => {
if (activeNodeId === null) return 1;
// Check if this link is connected to the active node
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
return (sourceId === activeNodeId || targetId === activeNodeId) ? 1 : 0.1;
});
}
}
export function unmount(containerId) {
if (simulation) {
simulation.stop();
}
if (resizeObserver) {
resizeObserver.disconnect();
}
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = ''; // clear svg
}
}
export function handleResize(containerId) {
const container = document.getElementById(containerId);
if (!container || !svgElement || !simulation) return;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
// If container is hidden (size is 0), skip resize to avoid collapsing coordinates to (0,0) or NaN
if (newWidth <= 0 || newHeight <= 0) return;
width = newWidth;
height = newHeight;
svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2));
const prevMobileMode = isMobileMode;
isMobileMode = window.innerWidth < 768;
if (isMobileMode !== prevMobileMode) {
setMobileMode(isMobileMode);
} else {
simulation.alpha(0.3).restart();
}
}
export function scrollToNode(id) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
export function pause() {
if (simulation) {
simulation.stop();
}
}
export function zoomIn() {
if (svgElement && zoomBehavior) {
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
}
}
export function zoomOut() {
if (svgElement && zoomBehavior) {
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
}
}
export function zoomReset() {
zoomToFit();
}
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
if (width <= 0 || height <= 0 || isNaN(width) || isNaN(height)) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
if (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15);
}
});
if (minX === Infinity || maxX === minX || maxY === minY) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const padding = 60;
const scale = Math.min(
(width - padding) / graphWidth,
(height - padding) / graphHeight,
1.2 // Max scale
);
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-midX, -midY)
);
}
export function clear() {
if (!rootGroup) return;
try {
rootGroup.select(".links-layer").selectAll("path").remove();
rootGroup.select(".nodes-layer").selectAll("g.node-group").remove();
if (badge) badge.style("display", "none");
if (simulation) {
simulation.stop();
const linkForce = simulation.force("link");
if (linkForce) {
linkForce.links([]);
}
simulation.nodes([]);
}
// Reset selections
link = null;
node = null;
// Reset D3 zoom transform to clean identity state
if (svgElement && zoomBehavior) {
svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
}
} catch (e) {
console.warn("Failed to clear force simulation safely:", e);
}
}