Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"clean": "rm -rf dist coverage",
"db:seed": "tsx src/scripts/seed.ts",
"create-admin": "tsx src/scripts/create-admin.ts",
"create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts"
"create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts",
"create-hierarchy-demo": "tsx src/scripts/create-hierarchy-demo.ts"
},
"dependencies": {
"@apollo/server": "^4.9.0",
Expand Down
30 changes: 30 additions & 0 deletions packages/server/src/scripts/create-hierarchy-demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { driver } from '../db.js';
import { createHierarchyDemo, hierarchyDemoExists } from '../services/hierarchyDemo.js';

async function ensureHierarchyDemo() {
console.log('🏗️ Ensuring hierarchical "graphs of graphs" demo exists...\n');
try {
if (await hierarchyDemoExists(driver)) {
console.log('⏭️ Hierarchy demo already exists - skipping creation\n');
} else {
const r = await createHierarchyDemo(driver);
console.log('\n========================================');
console.log(' Hierarchy Demo Created');
console.log('========================================');
console.log(`• Graphs: ${r.graphs} (1 overview + sub-graphs)`);
console.log(`• Work items: ${r.nodes}`);
console.log(`• Edges: ${r.edges}`);
console.log('• Shared: Yes (guest + all users, read-only)');
console.log('• Open "System Overview" and click a node to drill in');
console.log('========================================\n');
}
} catch (error: any) {
console.error('❌ Failed to create hierarchy demo:', error);
await driver.close();
process.exit(1);
}
await driver.close();
process.exit(0);
}

ensureHierarchyDemo();
242 changes: 242 additions & 0 deletions packages/server/src/services/hierarchyDemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { Driver } from 'neo4j-driver';

/**
* Guest-visible demo of an Altium-style HIERARCHY of graphs ("graphs of
* graphs"). A top OVERVIEW graph holds sheet-symbol nodes; each drills into a
* sub-graph (via the WorkItem.subgraph / DRILLS_INTO relationship). The
* hierarchy is the level-of-detail strategy — any single view renders one graph
* (a few dozen sheets at the overview, up to ~1000 in the perf showcase
* sub-graph), never all ~2600 nodes at once.
*
* Everything is createdBy:'system' + isShared:true so the GUEST account sees it.
* Idempotent and NON-destructive: it only creates if the overview graph is
* absent (unlike scripts/seed.ts which wipes the DB). Edges are canonical Edge
* nodes with both EDGE_SOURCE and EDGE_TARGET (no orphan edges).
*/

export const OVERVIEW_GRAPH_ID = 'overview-graph-shared';

const NODE_TYPES = ['TASK', 'FEATURE', 'BUG', 'MILESTONE', 'OUTCOME', 'IDEA'];
const STATUSES = ['NOT_STARTED', 'PROPOSED', 'PLANNED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED'];
const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'ENABLES', 'RELATES_TO'];

// Subsystems = sub-graphs. One large "Compute Core" (~1000 nodes) is the
// high-performance / LOD showcase; the rest are varied mid-size graphs.
interface Subsystem { key: string; name: string; size: number; }
const SUBSYSTEMS: Subsystem[] = [
{ key: 'compute', name: 'Compute Core', size: 1000 },
{ key: 'power', name: 'Power Management', size: 90 },
{ key: 'clocking', name: 'Clocking & PLL', size: 110 },
{ key: 'memctl', name: 'Memory Controller', size: 150 },
{ key: 'ddrphy', name: 'DDR PHY', size: 130 },
{ key: 'pcie', name: 'PCIe Root Complex', size: 160 },
{ key: 'usb', name: 'USB Subsystem', size: 120 },
{ key: 'ethernet', name: 'Ethernet MAC', size: 140 },
{ key: 'display', name: 'Display Pipeline', size: 170 },
{ key: 'audio', name: 'Audio Codec', size: 90 },
{ key: 'security', name: 'Security Enclave', size: 100 },
{ key: 'thermal', name: 'Thermal & Sensors', size: 110 },
{ key: 'ioexp', name: 'I/O Expander', size: 95 },
{ key: 'firmware', name: 'Firmware & Boot', size: 130 },
{ key: 'telemetry', name: 'Telemetry & Logging', size: 120 },
{ key: 'fabric', name: 'Interconnect Fabric', size: 180 },
];

interface NodeRow { id: string; type: string; title: string; description: string; status: string; priority: number; x: number; y: number; }
interface EdgeRow { id: string; s: string; t: string; type: string; weight: number; }

/** Grid positions centered on the origin so nodes load pinned in a real layout. */
function gridPositions(n: number, spacing: number): Array<{ x: number; y: number }> {
const cols = Math.ceil(Math.sqrt(n));
const half = (cols * spacing) / 2;
return Array.from({ length: n }, (_, i) => ({
x: (i % cols) * spacing - half,
y: Math.floor(i / cols) * spacing - half,
}));
}

/** A connected sub-graph: backbone chain + deterministic forward links (~1.4x). */
function buildSubgraph(graphId: string, size: number): { nodes: NodeRow[]; edges: EdgeRow[] } {
const pos = gridPositions(size, 140);
const nodes: NodeRow[] = Array.from({ length: size }, (_, i) => {
const type = NODE_TYPES[(i * 7) % NODE_TYPES.length];
return {
id: `${graphId}-n${i}`,
type,
title: `${type} ${i}`,
description: '',
status: STATUSES[i % STATUSES.length],
priority: ((i * 37) % 100) / 100,
x: pos[i].x,
y: pos[i].y,
};
});

const edges: EdgeRow[] = [];
const link = (a: string, b: string, t: string) =>
edges.push({ id: `${graphId}-e${edges.length}`, s: a, t: b, type: t, weight: 0.5 + (edges.length % 5) / 10 });
for (let i = 0; i + 1 < size; i++) link(nodes[i].id, nodes[i + 1].id, 'DEPENDS_ON');
let extra = Math.round(size * 1.4) - edges.length;
for (let i = 0; i < size && extra > 0; i++) {
const jump = 2 + ((i * 5) % Math.max(2, Math.floor(size / 4)));
const j = i + jump;
if (j < size) { link(nodes[i].id, nodes[j].id, EDGE_TYPES[i % EDGE_TYPES.length]); extra--; }
}
return { nodes, edges };
}

function chunk<T>(arr: T[], n: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
return out;
}

export async function hierarchyDemoExists(driver: Driver): Promise<boolean> {
const session = driver.session();
try {
const r = await session.run(`MATCH (g:Graph {id: $id}) RETURN count(g) > 0 AS exists`, { id: OVERVIEW_GRAPH_ID });
return r.records[0]?.get('exists') ?? false;
} finally {
await session.close();
}
}

async function createGraphNode(session: any, params: {
id: string; name: string; description: string; type: string; depth: number; path: string[]; parentGraphId: string | null; nodeCount: number; edgeCount: number; tags: string[];
}) {
await session.run(
`CREATE (g:Graph {
id: $id, name: $name, description: $description, type: $type, status: 'ACTIVE',
teamId: null, createdBy: 'system', tags: $tags, defaultRole: 'VIEWER',
parentGraphId: $parentGraphId, depth: $depth, path: $path, isShared: true,
nodeCount: $nodeCount, edgeCount: $edgeCount, contributorCount: 0,
lastActivity: datetime(), settings: '{}',
permissions: '{"public":"read","authenticated":"read"}',
shareSettings: '{"public":true,"readOnly":true}',
createdAt: datetime(), updatedAt: datetime()
})`,
params
);
}

async function insertNodesAndEdges(session: any, graphId: string, nodes: NodeRow[], edges: EdgeRow[]) {
for (const batch of chunk(nodes, 500)) {
await session.run(
`MATCH (g:Graph {id: $graphId})
UNWIND $nodes AS n
CREATE (w:WorkItem {
id: n.id, type: n.type, title: n.title, description: n.description, status: n.status,
positionX: toFloat(n.x), positionY: toFloat(n.y), positionZ: 0.0,
radius: 1.0, theta: 0.0, phi: 0.0, priority: toFloat(n.priority), priorityComp: 0.0,
tags: [], metadata: '{}', createdAt: datetime(), updatedAt: datetime()
})
CREATE (w)-[:BELONGS_TO]->(g)`,
{ graphId, nodes: batch }
);
}
for (const batch of chunk(edges, 700)) {
await session.run(
`UNWIND $edges AS ed
MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t})
CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() })
CREATE (e)-[:EDGE_SOURCE]->(s)
CREATE (e)-[:EDGE_TARGET]->(t)`,
{ edges: batch }
);
}
}

export async function createHierarchyDemo(driver: Driver): Promise<{ graphs: number; nodes: number; edges: number }> {
const session = driver.session();
let totalNodes = 0;
let totalEdges = 0;
try {
console.log('🏗️ Building hierarchical "graphs of graphs" demo...');

// 1) Build + populate each sub-graph.
for (const sub of SUBSYSTEMS) {
const subId = `subgraph-${sub.key}-shared`;
const { nodes, edges } = buildSubgraph(subId, sub.size);
await createGraphNode(session, {
id: subId,
name: sub.name,
description: `${sub.name} — a sub-sheet of the System Overview (${sub.size} work items).`,
type: 'SUBGRAPH',
depth: 1,
path: [OVERVIEW_GRAPH_ID],
parentGraphId: OVERVIEW_GRAPH_ID,
nodeCount: nodes.length,
edgeCount: edges.length,
tags: ['demo', 'subgraph', sub.key],
});
await insertNodesAndEdges(session, subId, nodes, edges);
totalNodes += nodes.length;
totalEdges += edges.length;
console.log(` • ${sub.name}: ${nodes.length} nodes / ${edges.length} edges`);
}

// 2) Overview graph: one sheet symbol per subsystem.
const sheetPos = gridPositions(SUBSYSTEMS.length, 320);
const sheets = SUBSYSTEMS.map((sub, i) => ({
id: `${OVERVIEW_GRAPH_ID}-sheet-${sub.key}`,
subgraphId: `subgraph-${sub.key}-shared`,
title: sub.name,
description: `Drill in to open the ${sub.name} sub-graph (${sub.size} work items).`,
x: sheetPos[i].x,
y: sheetPos[i].y,
}));
// Inter-sheet "wires": backbone chain + a few cross links (both endpoints in overview).
const sheetEdges: EdgeRow[] = [];
const wire = (a: string, b: string, t: string) =>
sheetEdges.push({ id: `${OVERVIEW_GRAPH_ID}-w${sheetEdges.length}`, s: a, t: b, type: t, weight: 0.8 });
for (let i = 0; i + 1 < sheets.length; i++) wire(sheets[i].id, sheets[i + 1].id, 'DEPENDS_ON');
for (let i = 0; i + 3 < sheets.length; i += 3) wire(sheets[i].id, sheets[i + 3].id, 'RELATES_TO');

await createGraphNode(session, {
id: OVERVIEW_GRAPH_ID,
name: 'System Overview',
description: 'Top-level overview — each node is a sub-sheet. Click a node to drill into its sub-graph (Altium-style hierarchy).',
type: 'PROJECT',
depth: 0,
path: [],
parentGraphId: null,
nodeCount: sheets.length,
edgeCount: sheetEdges.length,
tags: ['demo', 'overview', 'hierarchy'],
});

// Sheet WorkItems with subgraphId + DRILLS_INTO to their sub-graph.
await session.run(
`MATCH (g:Graph {id: $overviewId})
UNWIND $sheets AS sh
MATCH (sub:Graph {id: sh.subgraphId})
CREATE (w:WorkItem {
id: sh.id, type: 'OUTCOME', title: sh.title, description: sh.description, status: 'IN_PROGRESS',
positionX: toFloat(sh.x), positionY: toFloat(sh.y), positionZ: 0.0,
radius: 1.0, theta: 0.0, phi: 0.0, priority: 0.8, priorityComp: 0.0,
tags: ['sheet'], metadata: '{}', subgraphId: sh.subgraphId,
createdAt: datetime(), updatedAt: datetime()
})
CREATE (w)-[:BELONGS_TO]->(g)
CREATE (w)-[:DRILLS_INTO]->(sub)
CREATE (g)-[:PARENT_OF]->(sub)`,
{ overviewId: OVERVIEW_GRAPH_ID, sheets }
);
// Inter-sheet wires.
await session.run(
`UNWIND $edges AS ed
MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t})
CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() })
CREATE (e)-[:EDGE_SOURCE]->(s)
CREATE (e)-[:EDGE_TARGET]->(t)`,
{ edges: sheetEdges }
);
totalNodes += sheets.length;
totalEdges += sheetEdges.length;

console.log(`✅ Hierarchy demo: ${SUBSYSTEMS.length + 1} graphs, ${totalNodes} work items, ${totalEdges} edges`);
return { graphs: SUBSYSTEMS.length + 1, nodes: totalNodes, edges: totalEdges };
} finally {
await session.close();
}
}
12 changes: 11 additions & 1 deletion packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ interface InteractiveGraphVisualizationProps {
export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { currentGraph, availableGraphs } = useGraph();
const { currentGraph, availableGraphs, descendInto } = useGraph();
// descendInto from context isn't memoized; hold the latest in a ref so the
// D3-bound node click handler can call it without re-binding every render.
const descendIntoRef = useRef(descendInto);
descendIntoRef.current = descendInto;
const { currentUser } = useAuth();
const { showSuccess, showError } = useNotifications();
const navigate = useNavigate();
Expand Down Expand Up @@ -1036,6 +1040,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap

setIsConnecting(false);
setConnectionSource(null);
} else if (node.subgraphId) {
// Altium-style sheet symbol: a plain click descends into its sub-graph.
// (Grow/connect is handled above; drag is suppressed by mousedownNodeRef;
// edit/relationship icons stopPropagation, so this only fires on a plain
// click of a sheet node.) Called via ref to avoid re-binding the handler.
descendIntoRef.current(node.subgraphId);
} else {
// Handle node selection with 2-item ring buffer
setSelectedNodes(prev => {
Expand Down
32 changes: 29 additions & 3 deletions packages/web/src/contexts/GraphContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,17 @@ export function GraphProvider({ children }: GraphProviderProps) {
graphToSelect = parsedGraphs.find((g: any) => g.id === storedGraphId);
}

// Auto-select graph: either previously selected or first available
// Auto-select graph: previously selected, else a sensible default.
// Pick by identity (Welcome tutorial first, then the System Overview),
// never by array position — merge order isn't stable and shared/system
// demo graphs must not hijack the fresh-load graph.
if (parsedGraphs.length > 0) {
if (!currentGraph || !parsedGraphs.find((g: any) => g.id === currentGraph.id)) {
const selectedGraph = graphToSelect || parsedGraphs[0];
const preferredDefault =
parsedGraphs.find((g: any) => g.name === 'Welcome') ||
parsedGraphs.find((g: any) => g.id === 'overview-graph-shared') ||
parsedGraphs[0];
const selectedGraph = graphToSelect || preferredDefault;
setCurrentGraph(selectedGraph);
// Save to localStorage for persistence
localStorage.setItem('currentGraphId', selectedGraph.id);
Expand Down Expand Up @@ -660,10 +667,26 @@ export function GraphProvider({ children }: GraphProviderProps) {
const getGraphPath = (graphId: string): Graph[] => {
const graph = availableGraphs.find(g => g.id === graphId);
if (!graph?.path) return [];

return graph.path.map(pathId => availableGraphs.find(g => g.id === pathId)).filter(Boolean) as Graph[];
};

// Altium-style hierarchy navigation: descend into a node's sub-graph, ascend
// back up via the breadcrumb. Both reduce to selectGraph; the breadcrumb is
// derived from the target graph's `path` (ancestors) + itself.
const descendInto = async (subgraphId: string): Promise<void> => {
await selectGraph(subgraphId);
};

const ascendTo = async (graphId: string): Promise<void> => {
await selectGraph(graphId);
};

const getBreadcrumb = (): Graph[] => {
if (!currentGraph) return [];
return [...getGraphPath(currentGraph.id), currentGraph];
};

const getGraphDepth = (graphId: string): number => {
const graph = availableGraphs.find(g => g.id === graphId);
return graph?.depth || 0;
Expand Down Expand Up @@ -718,6 +741,9 @@ export function GraphProvider({ children }: GraphProviderProps) {
moveGraph,
getGraphPath,
getGraphChildren,
descendInto,
ascendTo,
getBreadcrumb,
shareGraph,
updatePermissions,
joinSharedGraph,
Expand Down
Loading
Loading