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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user