M3 graph view: /api/graph + force-directed SVG
- graph blueprint: GET /api/graph resolves note_links to target notes by
normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
editor with link navigation). Sidebar Graph entry + route; empty state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -97,6 +97,9 @@ async function signOut() {
|
|||||||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||||||
<Icon name="note" /> Notes
|
<Icon name="note" /> Notes
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
|
||||||
|
<Icon name="graph" /> Graph
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const paths: Record<string, string> = {
|
|||||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
||||||
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||||
|
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const router = createRouter({
|
|||||||
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||||
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||||
|
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { useNotesStore, type Note } from "../stores/notes";
|
||||||
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
|
interface GNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
}
|
||||||
|
interface GEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDTH = 1000;
|
||||||
|
const HEIGHT = 700;
|
||||||
|
|
||||||
|
const notes = useNotesStore();
|
||||||
|
const nodes = ref<GNode[]>([]);
|
||||||
|
const edges = ref<GEdge[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const editing = ref<Note | null>(null);
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
async function loadGraph() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ nodes: { id: string; title: string }[]; edges: GEdge[] }>("/api/graph");
|
||||||
|
const cx = WIDTH / 2;
|
||||||
|
const cy = HEIGHT / 2;
|
||||||
|
const count = Math.max(res.nodes.length, 1);
|
||||||
|
nodes.value = res.nodes.map((n, i) => {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
|
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
edges.value = res.edges;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
frame = 0;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
if (nodes.value.length) simulate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulate() {
|
||||||
|
const list = nodes.value;
|
||||||
|
const cx = WIDTH / 2;
|
||||||
|
const cy = HEIGHT / 2;
|
||||||
|
const byId = new Map(list.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
for (let j = i + 1; j < list.length; j++) {
|
||||||
|
const a = list[i];
|
||||||
|
const b = list[j];
|
||||||
|
let dx = a.x - b.x;
|
||||||
|
let dy = a.y - b.y;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < 0.01) {
|
||||||
|
d2 = 0.01;
|
||||||
|
dx = Math.random();
|
||||||
|
dy = Math.random();
|
||||||
|
}
|
||||||
|
const d = Math.sqrt(d2);
|
||||||
|
const force = 6000 / d2;
|
||||||
|
const fx = (dx / d) * force;
|
||||||
|
const fy = (dy / d) * force;
|
||||||
|
a.vx += fx;
|
||||||
|
a.vy += fy;
|
||||||
|
b.vx -= fx;
|
||||||
|
b.vy -= fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of edges.value) {
|
||||||
|
const s = byId.get(e.source);
|
||||||
|
const t = byId.get(e.target);
|
||||||
|
if (!s || !t) continue;
|
||||||
|
const dx = t.x - s.x;
|
||||||
|
const dy = t.y - s.y;
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||||
|
const diff = (d - 130) * 0.02;
|
||||||
|
const fx = (dx / d) * diff;
|
||||||
|
const fy = (dy / d) * diff;
|
||||||
|
s.vx += fx;
|
||||||
|
s.vy += fy;
|
||||||
|
t.vx -= fx;
|
||||||
|
t.vy -= fy;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of list) {
|
||||||
|
n.vx += (cx - n.x) * 0.002;
|
||||||
|
n.vy += (cy - n.y) * 0.002;
|
||||||
|
n.vx *= 0.85;
|
||||||
|
n.vy *= 0.85;
|
||||||
|
n.x += n.vx;
|
||||||
|
n.y += n.vy;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame++;
|
||||||
|
if (frame < 400) raf = requestAnimationFrame(simulate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeLines = computed(() => {
|
||||||
|
const byId = new Map(nodes.value.map((n) => [n.id, n]));
|
||||||
|
const out: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
||||||
|
for (const e of edges.value) {
|
||||||
|
const s = byId.get(e.source);
|
||||||
|
const t = byId.get(e.target);
|
||||||
|
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openNode(n: GNode) {
|
||||||
|
const found = notes.items.find((x) => x.id === n.id);
|
||||||
|
editing.value = found ?? (await notes.fetchOne(n.id));
|
||||||
|
}
|
||||||
|
function closeEditor() {
|
||||||
|
editing.value = null;
|
||||||
|
}
|
||||||
|
async function onNavigate(id: string) {
|
||||||
|
const found = notes.items.find((x) => x.id === id);
|
||||||
|
editing.value = found ?? (await notes.fetchOne(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadGraph);
|
||||||
|
onBeforeUnmount(() => cancelAnimationFrame(raf));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col p-4">
|
||||||
|
<h1 class="mb-3 text-lg font-semibold">Graph</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading graph…</div>
|
||||||
|
|
||||||
|
<div v-else-if="nodes.length === 0" class="py-24 text-center">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No connections yet</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-400">
|
||||||
|
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them
|
||||||
|
connected here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" class="h-full w-full" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<line
|
||||||
|
v-for="(l, i) in edgeLines"
|
||||||
|
:key="`e${i}`"
|
||||||
|
:x1="l.x1"
|
||||||
|
:y1="l.y1"
|
||||||
|
:x2="l.x2"
|
||||||
|
:y2="l.y2"
|
||||||
|
class="stroke-neutral-300 dark:stroke-neutral-700"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<g v-for="n in nodes" :key="n.id" class="cursor-pointer" @click="openNode(n)">
|
||||||
|
<circle :cx="n.x" :cy="n.y" r="8" class="fill-brand" />
|
||||||
|
<text
|
||||||
|
:x="n.x"
|
||||||
|
:y="n.y - 13"
|
||||||
|
text-anchor="middle"
|
||||||
|
class="fill-neutral-600 text-[12px] dark:fill-neutral-300"
|
||||||
|
>
|
||||||
|
{{ n.title }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="editing">
|
||||||
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -10,6 +10,7 @@ from . import __version__
|
|||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
|
from .graph import bp as graph_bp
|
||||||
from .labels import bp as labels_bp
|
from .labels import bp as labels_bp
|
||||||
from .notes import bp as notes_bp
|
from .notes import bp as notes_bp
|
||||||
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
from .settings import get_public_config, get_setting, load_or_create_secret_key
|
||||||
@@ -33,6 +34,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
app.register_blueprint(labels_bp)
|
app.register_blueprint(labels_bp)
|
||||||
|
app.register_blueprint(graph_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, g, jsonify
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from .auth import login_required
|
||||||
|
from .db import session_scope
|
||||||
|
from .models.note import Note
|
||||||
|
from .models.note_link import NoteLink
|
||||||
|
|
||||||
|
bp = Blueprint("graph", __name__, url_prefix="/api/graph")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("")
|
||||||
|
@login_required
|
||||||
|
async def get_graph():
|
||||||
|
"""Wiki-link graph: nodes are the owner's connected notes, edges are resolved
|
||||||
|
[[links]] (note_links.target_norm matched to a note's normalized title)."""
|
||||||
|
source = aliased(Note)
|
||||||
|
target = aliased(Note)
|
||||||
|
stmt = (
|
||||||
|
select(source.id, target.id)
|
||||||
|
.select_from(NoteLink)
|
||||||
|
.join(source, source.id == NoteLink.source_id)
|
||||||
|
.join(target, func.lower(func.trim(target.title)) == NoteLink.target_norm)
|
||||||
|
.where(
|
||||||
|
source.owner_id == g.user_id,
|
||||||
|
source.deleted_at.is_(None),
|
||||||
|
target.owner_id == g.user_id,
|
||||||
|
target.deleted_at.is_(None),
|
||||||
|
source.id != target.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async with session_scope() as db:
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
edges = []
|
||||||
|
node_ids: set = set()
|
||||||
|
seen: set = set()
|
||||||
|
for src_id, tgt_id in rows:
|
||||||
|
key = (src_id, tgt_id)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
edges.append({"source": str(src_id), "target": str(tgt_id)})
|
||||||
|
node_ids.add(src_id)
|
||||||
|
node_ids.add(tgt_id)
|
||||||
|
nodes = []
|
||||||
|
if node_ids:
|
||||||
|
note_rows = (await db.scalars(select(Note).where(Note.id.in_(node_ids)))).all()
|
||||||
|
nodes = [{"id": str(n.id), "title": n.title or "Untitled"} for n in note_rows]
|
||||||
|
return jsonify({"nodes": nodes, "edges": edges})
|
||||||
@@ -90,3 +90,9 @@ async def test_titles_requires_auth(app):
|
|||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/titles")
|
resp = await client.get("/api/notes/titles")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_graph_requires_auth(app):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = await client.get("/api/graph")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
Reference in New Issue
Block a user