synops/frontend/src/routes/graph/+page.svelte
vegard f092afd2ba Fjern mottak-konseptet: alle referanser peker til arbeidsflaten
- «Mottak» → «Arbeidsflaten» i alle tilbake-lenker
- goto('/workspace') → goto('/') i ContextHeader
- Slettet NodeEditor.svelte og NewChatDialog.svelte (kun brukt av mottak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:47:10 +00:00

668 lines
20 KiB
Svelte

<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; Arbeidsflaten</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 ?? null; 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>