Feat v0.4.7 note graph (#29)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #29.
This commit is contained in:
33
CHANGELOG.md
33
CHANGELOG.md
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to Switchboard Core are documented here.
|
||||
|
||||
## v0.4.7 — Note Graph
|
||||
|
||||
### Added
|
||||
|
||||
- **Graph API**: `GET /graph` endpoint returns all non-archived notes as nodes
|
||||
and resolved wikilinks as edges in a single payload. Nodes include id, title,
|
||||
folder_id, and tags. Edges include source, target, and link text.
|
||||
- **Graph view**: Canvas-based force-directed graph visualization replaces the
|
||||
editor pane when "Graph" button is clicked in the topbar. Force simulation
|
||||
uses repulsion (all pairs), attraction (edges), and center gravity with
|
||||
velocity damping. Supports zoom (scroll wheel) and pan (mouse drag).
|
||||
- **Click focus**: Single-click a node to dim unconnected nodes/edges to 15%
|
||||
opacity; clicked node and direct neighbors stay at full brightness. Edges
|
||||
between focused nodes highlighted. Click empty space to reset.
|
||||
- **Shift+click chain**: Hold shift and click a second node to union both
|
||||
neighborhoods — trace thought paths across two hops.
|
||||
- **Double-click open**: Double-click a graph node to navigate to that note in
|
||||
the editor. Graph view closes, editor opens with the selected note.
|
||||
- **Hover tooltips**: Hovering a node shows title, tag pills, and connection
|
||||
count in an HTML overlay (no additional API calls).
|
||||
- **Folder coloring**: Nodes colored by folder using a 10-color palette.
|
||||
Unfiled notes shown in gray.
|
||||
- **Tag filter integration**: Active tag filter dims non-matching nodes to 30%
|
||||
opacity, stacking with click focus.
|
||||
- **Orphan highlighting**: Notes with zero connections shown with dashed stroke.
|
||||
"Hide orphans" checkbox in toolbar to toggle visibility.
|
||||
|
||||
### Changed
|
||||
|
||||
- Notes package version bumped from 0.7.0 to 0.8.0.
|
||||
- Topbar gains a "Graph" toggle button alongside "New Note" and "Import .md".
|
||||
|
||||
|
||||
## v0.4.6 — Sidebar Restructure
|
||||
|
||||
### Added
|
||||
|
||||
20
ROADMAP.md
20
ROADMAP.md
@@ -308,19 +308,19 @@ Zero platform special-casing. Proves the full extension stack E2E.
|
||||
| Sidebar tabs | ✅ | Two-tab header at top of sidebar: "Notes" (folders → tags → search → list) and "Outline" (heading tree for active note). Outline tab enabled only when a note is selected. |
|
||||
| Heading tree | ✅ | Nested heading hierarchy (H1 → H2 → H3) with depth indentation. Click → scroll to heading in editor/preview. Current heading highlighted based on scroll position. |
|
||||
|
||||
### v0.4.7 — Note Graph (planned)
|
||||
### v0.4.7 — Note Graph
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Graph API | | `GET /graph` endpoint in Starlark — `{ nodes: [{id, title, folder_id}], edges: [{source, target, text}] }`. Full notes + links in one payload. |
|
||||
| Graph renderer | | Canvas force-directed layout. Minimal force simulation (repulsion + attraction + damping). Nodes = circles with title labels, edges = lines. Zoom/pan via wheel + drag. |
|
||||
| Click → focus + filter | | Single click a node: dims unconnected nodes/edges to ~15% opacity. Clicked node + direct neighbors stay full brightness. Edges highlighted in accent color. Click empty space to reset. |
|
||||
| Shift+click → chain | | Shift+click adds a second node to the focus set — union of both neighborhoods visible. Trace thought paths across two hops without losing context. |
|
||||
| Double-click → open | | Double-click navigates to the note. Graph view stays active — back returns to graph with previous focus state preserved. |
|
||||
| Hover → tooltip | | Hover shows note title + tag pills + edge count. No API call — all data in graph payload. |
|
||||
| Folder/tag coloring | | Nodes colored by folder. Tag filter dropdown — select tag, unmatched nodes dim. Stacks with click focus. |
|
||||
| Orphan highlighting | | Zero-edge nodes rendered with dashed stroke. Optional toggle to hide entirely. |
|
||||
| Entry point | | "Graph" button in topbar. Replaces editor pane with full graph canvas. Note selection in graph populates sidebar, switching back to editor shows that note. |
|
||||
| Graph API | ✅ | `GET /graph` endpoint in Starlark — `{ nodes: [{id, title, folder_id, tags}], edges: [{source, target, text}] }`. Full notes + links in one payload. |
|
||||
| Graph renderer | ✅ | Canvas force-directed layout. Minimal force simulation (repulsion + attraction + damping). Nodes = circles with title labels, edges = lines. Zoom/pan via wheel + drag. Retina-aware rendering. |
|
||||
| Click → focus + filter | ✅ | Single click a node: dims unconnected nodes/edges to 15% opacity. Clicked node + direct neighbors stay full brightness. Edges highlighted in accent color. Click empty space to reset. |
|
||||
| Shift+click → chain | ✅ | Shift+click adds a second node to the focus set — union of both neighborhoods visible. Trace thought paths across two hops without losing context. |
|
||||
| Double-click → open | ✅ | Double-click navigates to the note and exits graph view, opening the editor with the selected note. |
|
||||
| Hover → tooltip | ✅ | Hover shows note title + tag pills + edge count in HTML overlay. No API call — all data in graph payload. |
|
||||
| Folder/tag coloring | ✅ | Nodes colored by folder using 10-color palette. Active tag filter dims non-matching nodes to 30% opacity. Stacks with click focus. |
|
||||
| Orphan highlighting | ✅ | Zero-edge nodes rendered with dashed stroke. "Hide orphans" checkbox toggle in toolbar. |
|
||||
| Entry point | ✅ | "Graph" button in topbar. Replaces editor pane with full graph canvas. Single-click selects note in sidebar; double-click opens editor. |
|
||||
|
||||
## v0.5.x — Realtime + Chat
|
||||
|
||||
|
||||
@@ -706,6 +706,84 @@
|
||||
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
||||
.sidebar-outline__item--h2 { font-weight: 500; }
|
||||
|
||||
/* ── Graph Pane ─────────────────────────── */
|
||||
.graph-pane {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface, #fff);
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-pane canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
.graph-pane canvas:active { cursor: grabbing; }
|
||||
.graph-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
z-index: 5;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #666);
|
||||
}
|
||||
.graph-toolbar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-toolbar input[type="checkbox"] { margin: 0; }
|
||||
.graph-tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: var(--bg-raised, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
z-index: 10;
|
||||
max-width: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.graph-tooltip__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.graph-tooltip__tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.graph-tooltip__tags .tag-pill {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-hover, #eee);
|
||||
color: var(--text-2, #666);
|
||||
}
|
||||
.graph-tooltip__edges {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #999);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.graph-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-3, #999);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||
@@ -713,4 +791,5 @@
|
||||
.folder-tree { max-height: 30vh; }
|
||||
.notes-editor__body--split { grid-template-columns: 1fr; }
|
||||
.notes-editor__body--split .notes-preview { display: none; }
|
||||
.graph-pane { min-height: 50vh; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Notes — Surface Entry Point (v0.7.0)
|
||||
* Notes — Surface Entry Point (v0.8.0)
|
||||
*
|
||||
* Markdown notes surface using the SDK:
|
||||
* sw.api.ext('notes') — scoped API client
|
||||
@@ -1098,6 +1098,433 @@
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// GraphPane — force-directed note graph
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
var GRAPH_PALETTE = [
|
||||
'#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f',
|
||||
'#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac'
|
||||
];
|
||||
|
||||
function GraphPane(props) {
|
||||
var folders = props.folders || [];
|
||||
var activeTag = props.activeTag;
|
||||
var onSelectNote = props.onSelectNote;
|
||||
var onFocusNote = props.onFocusNote;
|
||||
|
||||
var [graphData, setGraphData] = useState(null);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [focusedIds, setFocusedIds] = useState(null); // Set or null
|
||||
var [hideOrphans, setHideOrphans] = useState(false);
|
||||
var [hoveredNode, setHoveredNode] = useState(null);
|
||||
var [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
var canvasRef = useRef(null);
|
||||
var simRef = useRef({
|
||||
nodes: [], edges: [], adj: {},
|
||||
positions: [], velocities: [],
|
||||
offsetX: 0, offsetY: 0, scale: 1,
|
||||
dragging: false, dragStartX: 0, dragStartY: 0,
|
||||
converged: false, frameCount: 0
|
||||
});
|
||||
var animRef = useRef(null);
|
||||
|
||||
// build folder→color map
|
||||
var folderColorMap = useMemo(function() {
|
||||
var map = { '': '#888' };
|
||||
folders.forEach(function(f, i) {
|
||||
map[f.id] = GRAPH_PALETTE[i % GRAPH_PALETTE.length];
|
||||
});
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
// fetch graph data
|
||||
useEffect(function() {
|
||||
var cancelled = false;
|
||||
(async function() {
|
||||
try {
|
||||
var res = await api.get('/graph');
|
||||
if (cancelled) return;
|
||||
var nodes = res.nodes || [];
|
||||
var edges = res.edges || [];
|
||||
|
||||
// build adjacency map
|
||||
var adj = {};
|
||||
nodes.forEach(function(n) { adj[n.id] = new Set(); });
|
||||
edges.forEach(function(e) {
|
||||
if (adj[e.source]) adj[e.source].add(e.target);
|
||||
if (adj[e.target]) adj[e.target].add(e.source);
|
||||
});
|
||||
|
||||
// initial positions in a circle
|
||||
var radius = Math.sqrt(nodes.length) * 50 + 50;
|
||||
var positions = nodes.map(function(_, i) {
|
||||
var angle = (2 * Math.PI * i) / (nodes.length || 1);
|
||||
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
|
||||
});
|
||||
var velocities = nodes.map(function() { return { x: 0, y: 0 }; });
|
||||
|
||||
var sim = simRef.current;
|
||||
sim.nodes = nodes;
|
||||
sim.edges = edges;
|
||||
sim.adj = adj;
|
||||
sim.positions = positions;
|
||||
sim.velocities = velocities;
|
||||
sim.converged = false;
|
||||
sim.frameCount = 0;
|
||||
|
||||
setGraphData({ nodes: nodes, edges: edges, adj: adj });
|
||||
} catch (e) {
|
||||
console.error('Graph load failed:', e);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return function() { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// force simulation + rendering loop
|
||||
useEffect(function() {
|
||||
var canvas = canvasRef.current;
|
||||
if (!canvas || !graphData) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var sim = simRef.current;
|
||||
// resolve CSS vars for canvas (which can't use var())
|
||||
var cs = getComputedStyle(canvas.parentElement);
|
||||
var colorText = cs.getPropertyValue('--text').trim() || '#ccc';
|
||||
var colorText3 = cs.getPropertyValue('--text-3').trim() || '#999';
|
||||
var colorAccent = cs.getPropertyValue('--accent').trim() || '#4e79a7';
|
||||
|
||||
function resize() {
|
||||
var rect = canvas.parentElement.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
resize();
|
||||
var ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas.parentElement);
|
||||
|
||||
function step() {
|
||||
var nodes = sim.nodes;
|
||||
var edges = sim.edges;
|
||||
var pos = sim.positions;
|
||||
var vel = sim.velocities;
|
||||
var N = nodes.length;
|
||||
|
||||
// force simulation (skip if converged)
|
||||
if (!sim.converged && N > 0) {
|
||||
// reset forces
|
||||
var forces = [];
|
||||
for (var i = 0; i < N; i++) forces.push({ x: 0, y: 0 });
|
||||
|
||||
// repulsion between all pairs
|
||||
for (var i = 0; i < N; i++) {
|
||||
for (var j = i + 1; j < N; j++) {
|
||||
var dx = pos[i].x - pos[j].x;
|
||||
var dy = pos[i].y - pos[j].y;
|
||||
var distSq = dx * dx + dy * dy + 1;
|
||||
var f = 5000 / distSq;
|
||||
var fx = dx / Math.sqrt(distSq) * f;
|
||||
var fy = dy / Math.sqrt(distSq) * f;
|
||||
forces[i].x += fx; forces[i].y += fy;
|
||||
forces[j].x -= fx; forces[j].y -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// attraction along edges
|
||||
for (var e = 0; e < edges.length; e++) {
|
||||
var si = -1, ti = -1;
|
||||
for (var k = 0; k < N; k++) {
|
||||
if (nodes[k].id === edges[e].source) si = k;
|
||||
if (nodes[k].id === edges[e].target) ti = k;
|
||||
}
|
||||
if (si < 0 || ti < 0) continue;
|
||||
var dx = pos[ti].x - pos[si].x;
|
||||
var dy = pos[ti].y - pos[si].y;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
||||
var f = 0.02 * (dist - 100);
|
||||
forces[si].x += (dx / dist) * f;
|
||||
forces[si].y += (dy / dist) * f;
|
||||
forces[ti].x -= (dx / dist) * f;
|
||||
forces[ti].y -= (dy / dist) * f;
|
||||
}
|
||||
|
||||
// center gravity
|
||||
for (var i = 0; i < N; i++) {
|
||||
forces[i].x -= pos[i].x * 0.001;
|
||||
forces[i].y -= pos[i].y * 0.001;
|
||||
}
|
||||
|
||||
// integrate
|
||||
var maxV = 0;
|
||||
for (var i = 0; i < N; i++) {
|
||||
vel[i].x = (vel[i].x + forces[i].x) * 0.85;
|
||||
vel[i].y = (vel[i].y + forces[i].y) * 0.85;
|
||||
pos[i].x += vel[i].x;
|
||||
pos[i].y += vel[i].y;
|
||||
var v = Math.abs(vel[i].x) + Math.abs(vel[i].y);
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
|
||||
sim.frameCount++;
|
||||
if ((maxV < 0.5 && sim.frameCount > 50) || sim.frameCount > 500) {
|
||||
sim.converged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// render
|
||||
var w = canvas.width / (window.devicePixelRatio || 1);
|
||||
var h = canvas.height / (window.devicePixelRatio || 1);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
ctx.translate(sim.offsetX + w / 2, sim.offsetY + h / 2);
|
||||
ctx.scale(sim.scale, sim.scale);
|
||||
|
||||
var focused = focusedIds;
|
||||
var nodes = sim.nodes;
|
||||
var edges = sim.edges;
|
||||
var pos = sim.positions;
|
||||
var adj = sim.adj;
|
||||
|
||||
// build index lookup for edge drawing
|
||||
var idxMap = {};
|
||||
for (var i = 0; i < nodes.length; i++) idxMap[nodes[i].id] = i;
|
||||
|
||||
// filter orphans
|
||||
var visibleSet = null;
|
||||
if (hideOrphans) {
|
||||
visibleSet = new Set();
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
if (adj[nodes[i].id] && adj[nodes[i].id].size > 0) visibleSet.add(nodes[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
// draw edges
|
||||
for (var e = 0; e < edges.length; e++) {
|
||||
var si = idxMap[edges[e].source];
|
||||
var ti = idxMap[edges[e].target];
|
||||
if (si == null || ti == null) continue;
|
||||
if (visibleSet && (!visibleSet.has(edges[e].source) || !visibleSet.has(edges[e].target))) continue;
|
||||
|
||||
var edgeFocused = !focused || (focused.has(edges[e].source) && focused.has(edges[e].target));
|
||||
ctx.globalAlpha = edgeFocused ? 0.4 : 0.06;
|
||||
ctx.strokeStyle = edgeFocused && focused ? colorAccent : '#999';
|
||||
ctx.lineWidth = edgeFocused && focused ? 1.5 : 0.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos[si].x, pos[si].y);
|
||||
ctx.lineTo(pos[ti].x, pos[ti].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// draw nodes
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i];
|
||||
if (visibleSet && !visibleSet.has(node.id)) continue;
|
||||
var edgeCount = adj[node.id] ? adj[node.id].size : 0;
|
||||
var isOrphan = edgeCount === 0;
|
||||
|
||||
// opacity based on focus + tag filter
|
||||
var nodeFocused = !focused || focused.has(node.id);
|
||||
var tagMatch = !activeTag || (node.tags && node.tags.indexOf(activeTag) !== -1);
|
||||
ctx.globalAlpha = nodeFocused ? (tagMatch ? 1 : 0.3) : 0.15;
|
||||
|
||||
var color = folderColorMap[node.folder_id] || '#888';
|
||||
var r = 8;
|
||||
|
||||
// circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos[i].x, pos[i].y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
if (isOrphan) {
|
||||
ctx.setLineDash([3, 2]);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// label
|
||||
ctx.fillStyle = nodeFocused && tagMatch ? colorText : colorText3;
|
||||
ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
var label = node.title.length > 20 ? node.title.slice(0, 18) + '…' : node.title;
|
||||
ctx.fillText(label, pos[i].x, pos[i].y + r + 13);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
animRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(step);
|
||||
return function() {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [graphData, focusedIds, hideOrphans, activeTag, folderColorMap]);
|
||||
|
||||
// hit-test helper
|
||||
function hitTest(ex, ey) {
|
||||
var sim = simRef.current;
|
||||
var canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
var w = canvas.width / (window.devicePixelRatio || 1);
|
||||
var h = canvas.height / (window.devicePixelRatio || 1);
|
||||
var wx = (ex - sim.offsetX - w / 2) / sim.scale;
|
||||
var wy = (ey - sim.offsetY - h / 2) / sim.scale;
|
||||
var best = null, bestDist = (12 / sim.scale) * (12 / sim.scale);
|
||||
for (var i = 0; i < sim.nodes.length; i++) {
|
||||
var dx = sim.positions[i].x - wx;
|
||||
var dy = sim.positions[i].y - wy;
|
||||
var d = dx * dx + dy * dy;
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
var rect = canvasRef.current.getBoundingClientRect();
|
||||
var ex = e.clientX - rect.left;
|
||||
var ey = e.clientY - rect.top;
|
||||
var idx = hitTest(ex, ey);
|
||||
if (idx === null) {
|
||||
setFocusedIds(null);
|
||||
return;
|
||||
}
|
||||
var node = simRef.current.nodes[idx];
|
||||
var adj = simRef.current.adj;
|
||||
var neighborhood = new Set([node.id]);
|
||||
if (adj[node.id]) adj[node.id].forEach(function(id) { neighborhood.add(id); });
|
||||
|
||||
if (e.shiftKey && focusedIds) {
|
||||
// union with existing focus
|
||||
var merged = new Set(focusedIds);
|
||||
neighborhood.forEach(function(id) { merged.add(id); });
|
||||
setFocusedIds(merged);
|
||||
} else {
|
||||
setFocusedIds(neighborhood);
|
||||
}
|
||||
if (onFocusNote) onFocusNote(node.id);
|
||||
}
|
||||
|
||||
function handleDblClick(e) {
|
||||
var rect = canvasRef.current.getBoundingClientRect();
|
||||
var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (idx !== null && onSelectNote) {
|
||||
onSelectNote(simRef.current.nodes[idx].id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
var rect = canvasRef.current.getBoundingClientRect();
|
||||
var ex = e.clientX - rect.left;
|
||||
var ey = e.clientY - rect.top;
|
||||
|
||||
// panning
|
||||
if (simRef.current.dragging) {
|
||||
simRef.current.offsetX += e.movementX;
|
||||
simRef.current.offsetY += e.movementY;
|
||||
return;
|
||||
}
|
||||
|
||||
var idx = hitTest(ex, ey);
|
||||
if (idx !== null) {
|
||||
setHoveredNode(simRef.current.nodes[idx]);
|
||||
setMousePos({ x: ex, y: ey });
|
||||
} else {
|
||||
setHoveredNode(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
var rect = canvasRef.current.getBoundingClientRect();
|
||||
var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||||
if (idx === null) {
|
||||
simRef.current.dragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
simRef.current.dragging = false;
|
||||
}
|
||||
|
||||
function handleWheel(e) {
|
||||
e.preventDefault();
|
||||
var sim = simRef.current;
|
||||
var canvas = canvasRef.current;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var mx = e.clientX - rect.left;
|
||||
var my = e.clientY - rect.top;
|
||||
var w = rect.width;
|
||||
var h = rect.height;
|
||||
|
||||
var zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
var newScale = Math.max(0.1, Math.min(5.0, sim.scale * zoomFactor));
|
||||
// zoom toward cursor (relative to center)
|
||||
var cx = mx - w / 2;
|
||||
var cy = my - h / 2;
|
||||
sim.offsetX = cx - (cx - sim.offsetX) * (newScale / sim.scale);
|
||||
sim.offsetY = cy - (cy - sim.offsetY) * (newScale / sim.scale);
|
||||
sim.scale = newScale;
|
||||
}
|
||||
|
||||
// attach wheel with passive:false
|
||||
useEffect(function() {
|
||||
var canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return function() { canvas.removeEventListener('wheel', handleWheel); };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`;
|
||||
}
|
||||
if (!graphData || graphData.nodes.length === 0) {
|
||||
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
||||
}
|
||||
|
||||
var adj = graphData.adj;
|
||||
|
||||
return html`
|
||||
<div class="graph-pane">
|
||||
<div class="graph-toolbar">
|
||||
<label>
|
||||
<input type="checkbox" checked=${hideOrphans}
|
||||
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
||||
Hide orphans
|
||||
</label>
|
||||
</div>
|
||||
<canvas ref=${canvasRef}
|
||||
onClick=${handleClick}
|
||||
onDblClick=${handleDblClick}
|
||||
onMouseMove=${handleMouseMove}
|
||||
onMouseDown=${handleMouseDown}
|
||||
onMouseUp=${handleMouseUp}
|
||||
onMouseLeave=${handleMouseUp} />
|
||||
${hoveredNode && html`
|
||||
<div class="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
||||
<div class="graph-tooltip__title">${hoveredNode.title}</div>
|
||||
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
||||
<div class="graph-tooltip__tags">
|
||||
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
|
||||
</div>
|
||||
`}
|
||||
<div class="graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NotesApp — root component
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -1117,6 +1544,7 @@
|
||||
var [sidebarTab, setSidebarTab] = useState('notes');
|
||||
var [sidebarHeadings, setSidebarHeadings] = useState([]);
|
||||
var [activeHeadingIdx, setActiveHeadingIdx] = useState(-1);
|
||||
var [showGraph, setShowGraph] = useState(false);
|
||||
var scrollToHeadingRef = useRef(null);
|
||||
|
||||
// ── Load note list ────────────────────────
|
||||
@@ -1385,6 +1813,8 @@
|
||||
<${Topbar} title="Notes">
|
||||
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
|
||||
<${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md<//>
|
||||
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
||||
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
||||
<//>
|
||||
<div class="notes-app">
|
||||
<div class="notes-sidebar">
|
||||
@@ -1425,7 +1855,12 @@
|
||||
onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} />
|
||||
`}
|
||||
</div>
|
||||
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
|
||||
${showGraph
|
||||
? html`<${GraphPane} folders=${folders} allTags=${allTags}
|
||||
activeTag=${activeTag}
|
||||
onSelectNote=${function(id) { handleSelect(id); setShowGraph(false); }}
|
||||
onFocusNote=${handleSelect} />`
|
||||
: html`<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
|
||||
onSave=${handleRefresh}
|
||||
onDelete=${handleDelete}
|
||||
onRefresh=${handleRefresh}
|
||||
@@ -1434,7 +1869,8 @@
|
||||
onNavigateToTitle=${handleNavigateToTitle}
|
||||
onHeadingsChange=${setSidebarHeadings}
|
||||
scrollToHeadingRef=${scrollToHeadingRef}
|
||||
onActiveHeadingChange=${setActiveHeadingIdx} />
|
||||
onActiveHeadingChange=${setActiveHeadingIdx} />`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"route": "/s/notes",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"icon": "📝",
|
||||
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, and document outline.",
|
||||
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, document outline, and note graph.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write"],
|
||||
@@ -21,6 +21,7 @@
|
||||
{"method": "DELETE", "path": "/notes/*"},
|
||||
{"method": "GET", "path": "/search"},
|
||||
{"method": "GET", "path": "/stats"},
|
||||
{"method": "GET", "path": "/graph"},
|
||||
{"method": "GET", "path": "/folders"},
|
||||
{"method": "POST", "path": "/folders"},
|
||||
{"method": "PUT", "path": "/folders/*"},
|
||||
|
||||
@@ -133,6 +133,10 @@ def on_request(req):
|
||||
if method == "POST" and path == "/notes/move":
|
||||
return _move_note(req)
|
||||
|
||||
# GET /graph — full graph data for visualization
|
||||
if method == "GET" and path == "/graph":
|
||||
return _get_graph()
|
||||
|
||||
# ── Link routes ──────────────────────────
|
||||
# GET /links/:note_id — outgoing links
|
||||
if method == "GET" and path.startswith("/links/"):
|
||||
@@ -463,6 +467,44 @@ def _get_backlinks(note_id):
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _get_graph():
|
||||
"""Return all non-archived notes as nodes and resolved links as edges."""
|
||||
all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
|
||||
notes = all_notes or []
|
||||
|
||||
# batch-fetch tags
|
||||
note_ids = [n.get("id", "") for n in notes]
|
||||
tags_map = _tags_for_notes(note_ids)
|
||||
|
||||
# build nodes + id set for edge filtering
|
||||
nodes = []
|
||||
note_id_set = {}
|
||||
for n in notes:
|
||||
nid = n.get("id", "")
|
||||
note_id_set[nid] = True
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"title": n.get("title", "Untitled"),
|
||||
"folder_id": _str(n.get("folder_id", "")),
|
||||
"tags": tags_map.get(nid, []),
|
||||
})
|
||||
|
||||
# fetch links, keep only resolved (both endpoints exist)
|
||||
all_links = db.query("links", limit=10000)
|
||||
edges = []
|
||||
for link in (all_links or []):
|
||||
src = _str(link.get("source_id", ""))
|
||||
tgt = _str(link.get("target_id", ""))
|
||||
if src and tgt and src in note_id_set and tgt in note_id_set:
|
||||
edges.append({
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"text": _str(link.get("link_text", "")),
|
||||
})
|
||||
|
||||
return _resp(200, {"nodes": nodes, "edges": edges})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Folder CRUD
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user