feat(ui/graph): Knowledge Graph Refinement and Sidebar Hierarchy (#25)

This PR addresses several UI/UX and architectural refinements for the Knowledge Graph and Intelligence Sidebar.

### Key Changes:
- **Knowledge Graph (#21, #22)**:
  - Implemented \"pill-shaped\" nodes with dynamic label truncation and SVG tooltips.
  - Added bound-constrained simulation to keep nodes within the viewport.
  - Integrated `ResizeObserver` for dynamic layout handling.
  - Implemented Zoom-to-Fit functionality.
  - Enforced unique concept IDs in AI prompts and hardened JS logic to prevent multi-selection bugs.
- **Intelligence Sidebar (#23)**:
  - Improved visual depth with a radial gradient background for the graph.
  - Increased sidebar divider contrast for better layering.
  - Transformed graph controls into a floating glassmorphism panel.
  - Relocated the \"Logout\" action to the toolbar bottom and rebranded it as \"Exit\".

Fixes #21
Fixes #22
Fixes #23

Reviewed-on: #25
Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
This commit was merged in pull request #25.
This commit is contained in:
2026-05-09 09:36:23 +00:00
committed by Marek Jaisński
parent 9e77aee231
commit 34794db209
11 changed files with 144 additions and 47 deletions
@@ -1,10 +1,13 @@
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;
let simulation;
let zoomBehavior;
let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
@@ -18,7 +21,8 @@ export function mount(containerId, data, dotNetHelper) {
svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("width", "100%")
.attr("height", "100%");
.attr("height", "100%")
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
// Radial gradient for Nebula effect
const defs = svgElement.append("defs");
@@ -66,14 +70,19 @@ export function mount(containerId, data, dotNetHelper) {
svgElement.call(zoomBehavior).on("wheel.zoom", null);
resizeHandler = () => handleResize(containerId);
window.addEventListener('resize', resizeHandler);
// Use ResizeObserver for more reliable container size tracking
resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
handleResize(containerId);
}
});
resizeObserver.observe(container);
simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(50));
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
simulation.on("tick", () => {
if (link) {
@@ -86,7 +95,14 @@ export function mount(containerId, data, dotNetHelper) {
}
if (node) {
node.attr("transform", d => `translate(${d.x},${d.y})`);
node.attr("transform", d => {
// 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") {
@@ -168,11 +184,11 @@ export function updateData(data) {
g.append("rect")
.attr("class", "node-pill")
.attr("x", d => -(d.label.length * 4 + 10))
.attr("y", -12)
.attr("width", d => d.label.length * 8 + 20)
.attr("height", 24)
.attr("rx", 12)
.attr("x", d => -getPillWidth(d) / 2)
.attr("y", -15)
.attr("width", d => getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("fill", "rgba(20, 20, 20, 0.9)")
.attr("stroke", d => {
if (d.type === 'Definition') return 'var(--nexus-accent)';
@@ -182,11 +198,14 @@ export function updateData(data) {
.attr("stroke-width", 1);
g.append("text")
.text(d => d.label)
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 4)
.attr("y", 5)
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
.attr("font-size", "0.8rem");
g.append("title")
.text(d => d.label);
g.transition().duration(500).style("opacity", 1);
@@ -199,6 +218,9 @@ export function updateData(data) {
simulation.nodes(data.nodes);
simulation.force("link").links(data.links);
simulation.alpha(0.5).restart();
// Trigger zoom to fit after a short delay to allow simulation to settle
setTimeout(zoomToFit, 100);
}
function drag(simulation) {
@@ -225,6 +247,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) {
if (!svgElement || !node) return;
// 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);
@@ -232,20 +255,21 @@ export function setActiveNode(nodeId) {
return;
}
const d = targetNode.datum();
const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
// Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
targetNode.select(".node-pill").classed("nexus-node-active", true);
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
// Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId);
// Smooth transition
// 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)
@@ -274,8 +298,8 @@ export function unmount(containerId) {
if (simulation) {
simulation.stop();
}
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
if (resizeObserver) {
resizeObserver.disconnect();
}
const container = document.getElementById(containerId);
if (container) {
@@ -321,9 +345,43 @@ export function zoomOut() {
}
export function zoomReset() {
if (svgElement && zoomBehavior) {
svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity);
}
zoomToFit();
}
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
// Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => {
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) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
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
);
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-midX, -midY)
);
}
export function clear() {