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:
parent
cce1c73b9e
commit
7f5d23e0c6
8 changed files with 1693 additions and 2 deletions
751
frontend/package-lock.json
generated
751
frontend/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
||||||
"@tiptap/extension-placeholder": "^3.20.4",
|
"@tiptap/extension-placeholder": "^3.20.4",
|
||||||
"@tiptap/pm": "^3.20.4",
|
"@tiptap/pm": "^3.20.4",
|
||||||
"@tiptap/starter-kit": "^3.20.4",
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"wavesurfer.js": "^7.12.4"
|
"wavesurfer.js": "^7.12.4"
|
||||||
},
|
},
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|
@ -1793,12 +1795,303 @@
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
|
@ -1928,6 +2221,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
|
|
@ -1950,6 +2252,407 @@
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
|
@ -1959,6 +2662,15 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|
@ -2144,6 +2856,27 @@
|
||||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
"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"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
|
|
@ -2973,6 +3712,12 @@
|
||||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
|
|
@ -2995,6 +3740,12 @@
|
||||||
"node": ">=10"
|
"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": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|
@ -31,6 +32,7 @@
|
||||||
"@tiptap/extension-placeholder": "^3.20.4",
|
"@tiptap/extension-placeholder": "^3.20.4",
|
||||||
"@tiptap/pm": "^3.20.4",
|
"@tiptap/pm": "^3.20.4",
|
||||||
"@tiptap/starter-kit": "^3.20.4",
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"spacetimedb": "^2.0.4",
|
"spacetimedb": "^2.0.4",
|
||||||
"wavesurfer.js": "^7.12.4"
|
"wavesurfer.js": "^7.12.4"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,60 @@ export async function fetchSegmentsVersion(
|
||||||
return res.json();
|
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. */
|
/** Trigger re-transkripsjon for en media-node. */
|
||||||
export function retranscribe(
|
export function retranscribe(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,12 @@
|
||||||
>
|
>
|
||||||
Kalender{#if scheduledCount > 0} ({scheduledCount}){/if}
|
Kalender{#if scheduledCount > 0} ({scheduledCount}){/if}
|
||||||
</a>
|
</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
|
<button
|
||||||
onclick={handleNewBoard}
|
onclick={handleNewBoard}
|
||||||
disabled={isCreatingBoard}
|
disabled={isCreatingBoard}
|
||||||
|
|
|
||||||
668
frontend/src/routes/graph/+page.svelte
Normal file
668
frontend/src/routes/graph/+page.svelte
Normal 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">← 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
|
@ -149,6 +149,7 @@ async fn main() {
|
||||||
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
||||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||||
.route("/query/aliases", get(queries::query_aliases))
|
.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/transcription_versions", get(queries::query_transcription_versions))
|
||||||
.route("/query/segments_version", get(queries::query_segments_version))
|
.route("/query/segments_version", get(queries::query_segments_version))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.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.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
||||||
- [x] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
- [x] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
||||||
- [~] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
- [x] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
||||||
> Påbegynt: 2026-03-17T22:56
|
|
||||||
|
|
||||||
## Fase 10: AI og beriking
|
## Fase 10: AI og beriking
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue