Kunnskapsgraf: topic-noder, mentions-edges, visuell graf-visning (oppgave 9.4)

Backend:
- query_graph endpoint med rekursiv CTE-traversering (fokus + dybde)
- Filtrering på node_kind og edge_type, RLS-beskyttet
- Maks 200 noder, 1-3 hops dybde

Frontend:
- D3 force-directed graf på /graph med fargekodede noder
- Opprett topic-noder (node_kind='topic') direkte fra graf-visningen
- Opprett mentions-edges mellom vilkårlige noder
- Klikk for detaljer, dobbeltklikk for fokus, dra for å flytte
- Filter-legende for nodetyper og kanttyper
- Zoom, auto-fit, sidepanel med koblingsinfo
- Graf-knapp lagt til i mottaksflaten

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 23:01:57 +00:00
parent cce1c73b9e
commit 7f5d23e0c6
8 changed files with 1693 additions and 2 deletions

View file

@ -16,6 +16,7 @@
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"d3": "^7.9.0",
"spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4"
},
@ -24,6 +25,7 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/d3": "^7.4.3",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.1",
@ -1793,12 +1795,303 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@ -1928,6 +2221,15 @@
"node": ">=6"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -1950,6 +2252,407 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -1959,6 +2662,15 @@
"node": ">=0.10.0"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2144,6 +2856,27 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -2923,6 +3656,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@ -2973,6 +3712,12 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -2995,6 +3740,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",

View file

@ -16,6 +16,7 @@
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/d3": "^7.4.3",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.1",
@ -31,6 +32,7 @@
"@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"d3": "^7.9.0",
"spacetimedb": "^2.0.4",
"wavesurfer.js": "^7.12.4"
}

View file

@ -306,6 +306,60 @@ export async function fetchSegmentsVersion(
return res.json();
}
// =============================================================================
// Graf / Kunnskapsgraf
// =============================================================================
export interface GraphNode {
id: string;
node_kind: string;
title: string | null;
visibility: string;
metadata: Record<string, unknown>;
created_at: string;
}
export interface GraphEdge {
id: string;
source_id: string;
target_id: string;
edge_type: string;
metadata: Record<string, unknown>;
}
export interface GraphResponse {
nodes: GraphNode[];
edges: GraphEdge[];
}
export interface FetchGraphParams {
focusId?: string;
depth?: number;
edgeTypes?: string[];
nodeKinds?: string[];
}
/** Hent graf-data for visualisering. */
export async function fetchGraph(
accessToken: string,
params: FetchGraphParams = {}
): Promise<GraphResponse> {
const searchParams = new URLSearchParams();
if (params.focusId) searchParams.set('focus_id', params.focusId);
if (params.depth) searchParams.set('depth', String(params.depth));
if (params.edgeTypes?.length) searchParams.set('edge_types', params.edgeTypes.join(','));
if (params.nodeKinds?.length) searchParams.set('node_kinds', params.nodeKinds.join(','));
const res = await fetch(`${BASE_URL}/query/graph?${searchParams}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`graph failed (${res.status}): ${body}`);
}
return res.json();
}
/** Trigger re-transkripsjon for en media-node. */
export function retranscribe(
accessToken: string,

View file

@ -322,6 +322,12 @@
>
Kalender{#if scheduledCount > 0} ({scheduledCount}){/if}
</a>
<a
href="/graph"
class="rounded-lg bg-purple-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-purple-700"
>
Graf
</a>
<button
onclick={handleNewBoard}
disabled={isCreatingBoard}

View file

@ -0,0 +1,668 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import * as d3 from 'd3';
import { fetchGraph, createNode, createEdge, type GraphNode, type GraphEdge } from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
// =========================================================================
// State
// =========================================================================
let svgEl: SVGSVGElement | undefined = $state();
let graphNodes = $state<GraphNode[]>([]);
let graphEdges = $state<GraphEdge[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let focusId = $state<string | null>(null);
let depth = $state(2);
// Filters
let filterKinds = $state<string[]>([]);
let filterEdgeTypes = $state<string[]>([]);
// Topic creation
let showTopicForm = $state(false);
let newTopicTitle = $state('');
let isCreatingTopic = $state(false);
// Mentions edge creation
let showMentionsForm = $state(false);
let mentionsSourceId = $state('');
let mentionsTargetId = $state('');
let isCreatingMention = $state(false);
// Selected node for info panel
let selectedNode = $state<GraphNode | null>(null);
// =========================================================================
// Node kind colors and labels
// =========================================================================
const kindColors: Record<string, string> = {
topic: '#8b5cf6',
person: '#3b82f6',
team: '#10b981',
content: '#f59e0b',
communication: '#06b6d4',
collection: '#84cc16',
media: '#ec4899',
agent: '#6b7280'
};
const kindLabels: Record<string, string> = {
topic: 'Tema',
person: 'Person',
team: 'Team',
content: 'Innhold',
communication: 'Samtale',
collection: 'Samling',
media: 'Media',
agent: 'Agent'
};
const edgeTypeLabels: Record<string, string> = {
mentions: 'Nevner',
belongs_to: 'Tilhører',
owner: 'Eier',
member_of: 'Medlem av',
has_media: 'Har media',
scheduled: 'Planlagt',
status: 'Status',
part_of: 'Del av'
};
function nodeColor(kind: string): string {
return kindColors[kind] ?? '#9ca3af';
}
function nodeRadius(kind: string): number {
if (kind === 'topic') return 12;
if (kind === 'person' || kind === 'team') return 10;
return 8;
}
// =========================================================================
// Data loading
// =========================================================================
async function loadGraph() {
if (!accessToken) return;
loading = true;
error = null;
try {
const params: Record<string, unknown> = { depth };
if (focusId) params.focusId = focusId;
if (filterKinds.length) params.nodeKinds = filterKinds;
if (filterEdgeTypes.length) params.edgeTypes = filterEdgeTypes;
const resp = await fetchGraph(accessToken, params as any);
graphNodes = resp.nodes;
graphEdges = resp.edges;
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
// Load on mount and when filters change
onMount(() => {
// Check URL for focus parameter
const url = new URL(window.location.href);
const fid = url.searchParams.get('focus');
if (fid) focusId = fid;
loadGraph();
});
// =========================================================================
// D3 Force Graph
// =========================================================================
interface SimNode extends d3.SimulationNodeDatum {
id: string;
node_kind: string;
title: string | null;
visibility: string;
}
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
id: string;
edge_type: string;
}
let simulation: d3.Simulation<SimNode, SimLink> | null = null;
function renderGraph() {
if (!svgEl || graphNodes.length === 0) return;
// Clear previous
d3.select(svgEl).selectAll('*').remove();
if (simulation) simulation.stop();
const width = svgEl.clientWidth;
const height = svgEl.clientHeight;
const svg = d3.select(svgEl);
// Zoom container
const g = svg.append('g');
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Build data
const nodeMap = new Map(graphNodes.map(n => [n.id, n]));
const simNodes: SimNode[] = graphNodes.map(n => ({
id: n.id,
node_kind: n.node_kind,
title: n.title,
visibility: n.visibility
}));
const simLinks: SimLink[] = graphEdges
.filter(e => nodeMap.has(e.source_id) && nodeMap.has(e.target_id))
.map(e => ({
id: e.id,
source: e.source_id,
target: e.target_id,
edge_type: e.edge_type
}));
// Simulation
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(20));
// Edge type colors
function edgeColor(type: string): string {
if (type === 'mentions') return '#8b5cf6';
if (type === 'belongs_to') return '#9ca3af';
if (type === 'owner') return '#3b82f6';
if (type === 'member_of') return '#10b981';
if (type === 'part_of') return '#f59e0b';
return '#d1d5db';
}
// Draw edges
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', d => edgeColor(d.edge_type))
.attr('stroke-width', d => d.edge_type === 'mentions' ? 2.5 : 1.5)
.attr('stroke-opacity', 0.6)
.attr('stroke-dasharray', d => d.edge_type === 'mentions' ? '' : '4,2');
// Edge labels
const linkLabel = g.append('g')
.selectAll('text')
.data(simLinks)
.join('text')
.attr('font-size', '9px')
.attr('fill', '#9ca3af')
.attr('text-anchor', 'middle')
.text(d => edgeTypeLabels[d.edge_type] ?? d.edge_type);
// Draw nodes
const node = g.append('g')
.selectAll<SVGCircleElement, SimNode>('circle')
.data(simNodes)
.join('circle')
.attr('r', d => nodeRadius(d.node_kind))
.attr('fill', d => nodeColor(d.node_kind))
.attr('stroke', d => focusId === d.id ? '#1f2937' : '#fff')
.attr('stroke-width', d => focusId === d.id ? 3 : 2)
.attr('cursor', 'pointer')
.on('click', (_event, d) => {
selectedNode = graphNodes.find(n => n.id === d.id) ?? null;
})
.on('dblclick', (_event, d) => {
focusId = d.id;
loadGraph();
})
.call(d3.drag<SVGCircleElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0);
d.fx = null;
d.fy = null;
})
);
// Node labels
const label = g.append('g')
.selectAll('text')
.data(simNodes)
.join('text')
.attr('font-size', '11px')
.attr('font-weight', d => d.node_kind === 'topic' ? '600' : '400')
.attr('fill', '#374151')
.attr('text-anchor', 'middle')
.attr('dy', d => nodeRadius(d.node_kind) + 14)
.text(d => {
const t = d.title || 'Uten tittel';
return t.length > 20 ? t.slice(0, 18) + '…' : t;
});
// Tick
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!);
linkLabel
.attr('x', d => ((d.source as SimNode).x! + (d.target as SimNode).x!) / 2)
.attr('y', d => ((d.source as SimNode).y! + (d.target as SimNode).y!) / 2 - 6);
node
.attr('cx', d => d.x!)
.attr('cy', d => d.y!);
label
.attr('x', d => d.x!)
.attr('y', d => d.y!);
});
// Auto-zoom to fit
simulation.on('end', () => {
const bounds = (g.node() as SVGGElement)?.getBBox();
if (!bounds || bounds.width === 0) return;
const padding = 40;
const scale = Math.min(
width / (bounds.width + padding * 2),
height / (bounds.height + padding * 2),
1.5
);
const tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
const ty = height / 2 - (bounds.y + bounds.height / 2) * scale;
svg.transition().duration(500).call(
zoom.transform,
d3.zoomIdentity.translate(tx, ty).scale(scale)
);
});
}
// Re-render when data changes
$effect(() => {
if (graphNodes.length > 0 && svgEl) {
renderGraph();
}
});
// =========================================================================
// Topic creation
// =========================================================================
async function handleCreateTopic() {
if (!accessToken || !nodeId || !newTopicTitle.trim() || isCreatingTopic) return;
isCreatingTopic = true;
try {
const { node_id } = await createNode(accessToken, {
node_kind: 'topic',
title: newTopicTitle.trim(),
visibility: 'readable'
});
await createEdge(accessToken, {
source_id: nodeId,
target_id: node_id,
edge_type: 'owner'
});
newTopicTitle = '';
showTopicForm = false;
await loadGraph();
} catch (e: any) {
error = e.message;
} finally {
isCreatingTopic = false;
}
}
// =========================================================================
// Mentions edge creation
// =========================================================================
async function handleCreateMention() {
if (!accessToken || !mentionsSourceId || !mentionsTargetId || isCreatingMention) return;
if (mentionsSourceId === mentionsTargetId) return;
isCreatingMention = true;
try {
await createEdge(accessToken, {
source_id: mentionsSourceId,
target_id: mentionsTargetId,
edge_type: 'mentions'
});
mentionsSourceId = '';
mentionsTargetId = '';
showMentionsForm = false;
await loadGraph();
} catch (e: any) {
error = e.message;
} finally {
isCreatingMention = false;
}
}
function resetFocus() {
focusId = null;
selectedNode = null;
loadGraph();
}
// =========================================================================
// Unique kinds/types in current data (for filters)
// =========================================================================
const availableKinds = $derived([...new Set(graphNodes.map(n => n.node_kind))].sort());
const availableEdgeTypes = $derived([...new Set(graphEdges.map(e => e.edge_type))].sort());
function toggleKindFilter(kind: string) {
if (filterKinds.includes(kind)) {
filterKinds = filterKinds.filter(k => k !== kind);
} else {
filterKinds = [...filterKinds, kind];
}
loadGraph();
}
function toggleEdgeTypeFilter(type: string) {
if (filterEdgeTypes.includes(type)) {
filterEdgeTypes = filterEdgeTypes.filter(t => t !== type);
} else {
filterEdgeTypes = [...filterEdgeTypes, type];
}
loadGraph();
}
</script>
<div class="flex flex-col h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white shrink-0">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900">Kunnskapsgraf</h1>
</div>
<div class="flex items-center gap-2">
{#if focusId}
<button
onclick={resetFocus}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
Vis alle
</button>
{/if}
<span class="text-xs text-gray-400">
{graphNodes.length} noder, {graphEdges.length} kanter
</span>
</div>
</div>
</header>
<!-- Toolbar -->
<div class="border-b border-gray-200 bg-white px-4 py-2 shrink-0">
<div class="flex flex-wrap items-center gap-2">
<!-- Depth control -->
<label class="flex items-center gap-1 text-xs text-gray-500">
Dybde:
<select
bind:value={depth}
onchange={() => loadGraph()}
class="rounded border border-gray-200 bg-white px-1 py-0.5 text-xs"
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
<div class="h-4 w-px bg-gray-200"></div>
<!-- Create topic -->
<button
onclick={() => { showTopicForm = !showTopicForm; showMentionsForm = false; }}
class="rounded bg-violet-100 px-2 py-1 text-xs font-medium text-violet-700 hover:bg-violet-200"
>
+ Nytt tema
</button>
<!-- Create mention -->
<button
onclick={() => { showMentionsForm = !showMentionsForm; showTopicForm = false; }}
class="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700 hover:bg-purple-200"
>
+ Kobling
</button>
<button
onclick={() => loadGraph()}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
Oppdater
</button>
</div>
<!-- Topic creation form -->
{#if showTopicForm}
<div class="mt-2 flex items-center gap-2">
<input
type="text"
bind:value={newTopicTitle}
placeholder="Temanavn…"
class="rounded border border-gray-200 px-2 py-1 text-sm focus:border-violet-400 focus:outline-none"
onkeydown={(e) => e.key === 'Enter' && handleCreateTopic()}
/>
<button
onclick={handleCreateTopic}
disabled={isCreatingTopic || !newTopicTitle.trim()}
class="rounded bg-violet-600 px-3 py-1 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-50"
>
{isCreatingTopic ? '…' : 'Opprett'}
</button>
<button
onclick={() => { showTopicForm = false; newTopicTitle = ''; }}
class="text-xs text-gray-500 hover:text-gray-700"
>
Avbryt
</button>
</div>
{/if}
<!-- Mentions edge creation form -->
{#if showMentionsForm}
<div class="mt-2 flex flex-wrap items-center gap-2">
<select
bind:value={mentionsSourceId}
class="rounded border border-gray-200 px-2 py-1 text-xs focus:border-purple-400 focus:outline-none"
>
<option value="">Fra node…</option>
{#each graphNodes.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')) as n}
<option value={n.id}>{n.title || n.id.slice(0, 8)} ({kindLabels[n.node_kind] ?? n.node_kind})</option>
{/each}
</select>
<span class="text-xs text-gray-400">nevner</span>
<select
bind:value={mentionsTargetId}
class="rounded border border-gray-200 px-2 py-1 text-xs focus:border-purple-400 focus:outline-none"
>
<option value="">Til node…</option>
{#each graphNodes.filter(n => n.id !== mentionsSourceId).sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')) as n}
<option value={n.id}>{n.title || n.id.slice(0, 8)} ({kindLabels[n.node_kind] ?? n.node_kind})</option>
{/each}
</select>
<button
onclick={handleCreateMention}
disabled={isCreatingMention || !mentionsSourceId || !mentionsTargetId}
class="rounded bg-purple-600 px-3 py-1 text-xs font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>
{isCreatingMention ? '…' : 'Opprett'}
</button>
<button
onclick={() => { showMentionsForm = false; mentionsSourceId = ''; mentionsTargetId = ''; }}
class="text-xs text-gray-500 hover:text-gray-700"
>
Avbryt
</button>
</div>
{/if}
</div>
<!-- Main content: graph + side panel -->
<div class="flex flex-1 overflow-hidden">
<!-- Graph SVG -->
<div class="flex-1 relative">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center">
<p class="text-sm text-gray-400">Laster graf…</p>
</div>
{:else if error}
<div class="absolute inset-0 flex items-center justify-center">
<p class="text-sm text-red-500">{error}</p>
</div>
{:else if graphNodes.length === 0}
<div class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<p class="text-sm text-gray-400">Ingen noder å vise.</p>
<p class="mt-1 text-xs text-gray-300">Opprett et tema for å komme i gang.</p>
</div>
</div>
{/if}
<svg bind:this={svgEl} class="w-full h-full"></svg>
<!-- Legend -->
<div class="absolute bottom-3 left-3 rounded-lg border border-gray-200 bg-white/90 p-2 text-xs backdrop-blur">
<div class="mb-1 font-medium text-gray-500">Nodetyper</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
{#each Object.entries(kindColors) as [kind, color]}
<button
onclick={() => toggleKindFilter(kind)}
class="flex items-center gap-1 {filterKinds.includes(kind) ? 'opacity-100 font-medium' : filterKinds.length > 0 ? 'opacity-40' : 'opacity-80'}"
>
<span class="inline-block h-2.5 w-2.5 rounded-full" style="background:{color}"></span>
{kindLabels[kind] ?? kind}
</button>
{/each}
</div>
<div class="mt-1.5 mb-1 font-medium text-gray-500">Kanttyper</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<button
onclick={() => toggleEdgeTypeFilter('mentions')}
class="flex items-center gap-1 {filterEdgeTypes.includes('mentions') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
>
<span class="inline-block h-0.5 w-4 bg-violet-500"></span>
Nevner
</button>
<button
onclick={() => toggleEdgeTypeFilter('belongs_to')}
class="flex items-center gap-1 {filterEdgeTypes.includes('belongs_to') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
>
<span class="inline-block h-0.5 w-4 bg-gray-400" style="border-top: 1px dashed"></span>
Tilhører
</button>
<button
onclick={() => toggleEdgeTypeFilter('part_of')}
class="flex items-center gap-1 {filterEdgeTypes.includes('part_of') ? 'font-medium' : filterEdgeTypes.length > 0 ? 'opacity-40' : 'opacity-80'}"
>
<span class="inline-block h-0.5 w-4 bg-amber-500" style="border-top: 1px dashed"></span>
Del av
</button>
</div>
<p class="mt-1.5 text-gray-300">Dobbeltklikk node for å fokusere. Dra for å flytte.</p>
</div>
</div>
<!-- Side panel: selected node info -->
{#if selectedNode}
<div class="w-72 shrink-0 border-l border-gray-200 bg-white p-4 overflow-y-auto">
<div class="flex items-start justify-between">
<h3 class="font-medium text-gray-900">{selectedNode.title || 'Uten tittel'}</h3>
<button
onclick={() => selectedNode = null}
class="text-gray-400 hover:text-gray-600"
>
&times;
</button>
</div>
<span
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs text-white"
style="background:{nodeColor(selectedNode.node_kind)}"
>
{kindLabels[selectedNode.node_kind] ?? selectedNode.node_kind}
</span>
<!-- Edges for this node -->
{#if graphEdges.filter(e => e.source_id === selectedNode?.id || e.target_id === selectedNode?.id).length > 0}
{@const nodeEdges = graphEdges.filter(e => e.source_id === selectedNode?.id || e.target_id === selectedNode?.id)}
<div class="mt-4">
<h4 class="text-xs font-medium text-gray-500">Koblinger ({nodeEdges.length})</h4>
<ul class="mt-1 space-y-1">
{#each nodeEdges as edge}
{@const otherId = edge.source_id === selectedNode?.id ? edge.target_id : edge.source_id}
{@const otherNode = graphNodes.find(n => n.id === otherId)}
{@const direction = edge.source_id === selectedNode?.id ? '→' : '←'}
<li class="text-xs text-gray-600">
<span class="text-gray-400">{direction}</span>
<span class="font-medium">{edgeTypeLabels[edge.edge_type] ?? edge.edge_type}</span>
<button
onclick={() => {
focusId = otherId;
loadGraph();
}}
class="ml-1 text-blue-600 hover:underline"
>
{otherNode?.title || otherId.slice(0, 8) + '…'}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<div class="mt-4 flex flex-col gap-1">
<button
onclick={() => { focusId = selectedNode?.id; loadGraph(); }}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
>
Fokuser på denne
</button>
<button
onclick={() => {
showMentionsForm = true;
showTopicForm = false;
mentionsSourceId = selectedNode?.id;
}}
class="rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200"
>
Opprett kobling fra denne
</button>
</div>
<p class="mt-4 text-xs text-gray-300 break-all">ID: {selectedNode.id}</p>
</div>
{/if}
</div>
</div>

View file

@ -149,6 +149,7 @@ async fn main() {
.route("/intentions/retranscribe", post(intentions::retranscribe))
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
.route("/query/aliases", get(queries::query_aliases))
.route("/query/graph", get(queries::query_graph))
.route("/query/transcription_versions", get(queries::query_transcription_versions))
.route("/query/segments_version", get(queries::query_segments_version))
.layer(TraceLayer::new_for_http())

View file

@ -772,6 +772,216 @@ pub async fn query_board(
}))
}
// =============================================================================
// GET /query/graph — graf-traversering fra en fokusnode
// =============================================================================
#[derive(Deserialize)]
pub struct QueryGraphRequest {
/// Fokusnode å starte traverseringen fra. Valgfritt — uten returneres hele grafen.
pub focus_id: Option<Uuid>,
/// Maks dybde for traversering (1-3). Default: 2.
pub depth: Option<i32>,
/// Filtrer på edge_type (komma-separert). Valgfritt.
pub edge_types: Option<String>,
/// Filtrer på node_kind (komma-separert). Valgfritt.
pub node_kinds: Option<String>,
}
#[derive(Serialize)]
pub struct GraphNode {
pub id: Uuid,
pub node_kind: String,
pub title: Option<String>,
pub visibility: String,
pub metadata: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Serialize)]
pub struct GraphEdge {
pub id: Uuid,
pub source_id: Uuid,
pub target_id: Uuid,
pub edge_type: String,
pub metadata: serde_json::Value,
}
#[derive(Serialize)]
pub struct QueryGraphResponse {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
/// GET /query/graph?focus_id=...&depth=...&edge_types=...&node_kinds=...
///
/// Returnerer noder og edges for graf-visualisering.
/// Med focus_id: traverserer grafen N ledd ut fra fokusnode.
/// Uten focus_id: returnerer alle synlige noder (maks 200) med edges mellom dem.
pub async fn query_graph(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QueryGraphRequest>,
) -> Result<Json<QueryGraphResponse>, (StatusCode, Json<ErrorResponse>)> {
let depth = params.depth.unwrap_or(2).clamp(1, 3);
let edge_type_filter: Option<Vec<String>> = params.edge_types.as_ref().map(|s| {
s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
});
let node_kind_filter: Option<Vec<String>> = params.node_kinds.as_ref().map(|s| {
s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
});
let result = run_query_graph(
&state.db,
user.node_id,
params.focus_id,
depth,
&edge_type_filter,
&node_kind_filter,
)
.await;
match result {
Ok(resp) => Ok(Json(resp)),
Err(e) => {
tracing::error!(error = %e, "query_graph feilet");
Err(internal_error("Databasefeil ved graf-spørring"))
}
}
}
async fn run_query_graph(
db: &PgPool,
user_node_id: Uuid,
focus_id: Option<Uuid>,
depth: i32,
edge_type_filter: &Option<Vec<String>>,
node_kind_filter: &Option<Vec<String>>,
) -> Result<QueryGraphResponse, sqlx::Error> {
let mut tx = db.begin().await?;
set_rls_context(&mut tx, user_node_id).await?;
let (nodes, edges) = if let Some(focus) = focus_id {
// Traverser fra fokusnode med rekursiv CTE
let node_rows = sqlx::query_as::<_, (Uuid, String, Option<String>, String, serde_json::Value, chrono::DateTime<chrono::Utc>)>(
r#"
WITH RECURSIVE reachable(id, depth) AS (
SELECT $1::uuid, 0
UNION
SELECT CASE WHEN e.source_id = r.id THEN e.target_id ELSE e.source_id END, r.depth + 1
FROM reachable r
JOIN edges e ON (e.source_id = r.id OR e.target_id = r.id)
WHERE r.depth < $2
)
SELECT DISTINCT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at
FROM reachable r
JOIN nodes n ON n.id = r.id
ORDER BY n.created_at DESC
LIMIT 200
"#,
)
.bind(focus)
.bind(depth)
.fetch_all(&mut *tx)
.await?;
// Samle opp node-IDer for å filtrere edges
let node_ids: Vec<Uuid> = node_rows.iter().map(|r| r.0).collect();
let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>(
r#"
SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata
FROM edges e
WHERE e.source_id = ANY($1) AND e.target_id = ANY($1)
AND e.system = false
"#,
)
.bind(&node_ids)
.fetch_all(&mut *tx)
.await?;
(node_rows, edge_rows)
} else {
// Ingen fokus — returner alle synlige noder (begrenset)
let node_rows = sqlx::query_as::<_, (Uuid, String, Option<String>, String, serde_json::Value, chrono::DateTime<chrono::Utc>)>(
r#"
SELECT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at
FROM nodes n
ORDER BY n.created_at DESC
LIMIT 200
"#,
)
.fetch_all(&mut *tx)
.await?;
let node_ids: Vec<Uuid> = node_rows.iter().map(|r| r.0).collect();
let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>(
r#"
SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata
FROM edges e
WHERE e.source_id = ANY($1) AND e.target_id = ANY($1)
AND e.system = false
"#,
)
.bind(&node_ids)
.fetch_all(&mut *tx)
.await?;
(node_rows, edge_rows)
};
tx.commit().await?;
// Appliser klientside-filtre
let mut graph_nodes: Vec<GraphNode> = nodes
.into_iter()
.map(|(id, node_kind, title, visibility, metadata, created_at)| GraphNode {
id,
node_kind,
title,
visibility,
metadata,
created_at,
})
.collect();
if let Some(kinds) = node_kind_filter {
if !kinds.is_empty() {
// Behold alltid fokusnode selv om den ikke matcher filteret
graph_nodes.retain(|n| kinds.contains(&n.node_kind) || focus_id == Some(n.id));
}
}
let visible_ids: std::collections::HashSet<Uuid> =
graph_nodes.iter().map(|n| n.id).collect();
let mut graph_edges: Vec<GraphEdge> = edges
.into_iter()
.map(|(id, source_id, target_id, edge_type, metadata)| GraphEdge {
id,
source_id,
target_id,
edge_type,
metadata,
})
.filter(|e| visible_ids.contains(&e.source_id) && visible_ids.contains(&e.target_id))
.collect();
if let Some(types) = edge_type_filter {
if !types.is_empty() {
graph_edges.retain(|e| types.contains(&e.edge_type));
}
}
Ok(QueryGraphResponse {
nodes: graph_nodes,
edges: graph_edges,
})
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -112,8 +112,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
- [x] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
- [x] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
- [~] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
> Påbegynt: 2026-03-17T22:56
- [x] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
## Fase 10: AI og beriking