Feat v0.4.7 note graph
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 22s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m48s
CI/CD / test-sqlite (pull_request) Successful in 3m5s
CI/CD / build-and-deploy (pull_request) Successful in 2m29s

Canvas force-directed graph view for notes surface. GET /graph API
endpoint returns all non-archived notes as nodes (with tags) and
resolved wikilinks as edges. Force simulation with repulsion,
attraction, and center gravity. Click focus dims unconnected nodes,
shift+click chains neighborhoods, double-click opens note. Hover
tooltips show title, tags, and connection count. Nodes colored by
folder (10-color palette), tag filter dims non-matching. Orphan
nodes shown with dashed stroke and hide toggle. Zoom/pan via scroll
wheel and mouse drag. Retina-aware canvas rendering with CSS
variable resolution via getComputedStyle.

Notes package 0.7.0 → 0.8.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 08:59:12 +00:00
parent 50d991001d
commit 6acfeab456
7 changed files with 615 additions and 24 deletions

View File

@@ -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; }
}

View File

@@ -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,16 +1855,22 @@
onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} />
`}
</div>
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh}
onTagsChanged=${handleTagsChanged}
onNavigate=${handleSelect}
onNavigateToTitle=${handleNavigateToTitle}
onHeadingsChange=${setSidebarHeadings}
scrollToHeadingRef=${scrollToHeadingRef}
onActiveHeadingChange=${setActiveHeadingIdx} />
${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}
onTagsChanged=${handleTagsChanged}
onNavigate=${handleSelect}
onNavigateToTitle=${handleNavigateToTitle}
onHeadingsChange=${setSidebarHeadings}
scrollToHeadingRef=${scrollToHeadingRef}
onActiveHeadingChange=${setActiveHeadingIdx} />`
}
</div>
`;
}

View File

@@ -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/*"},

View File

@@ -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
# ═══════════════════════════════════════════════