Frontend: entiteter, graf-visualisering og #-autocomplete
- EntitiesBlock: liste med søk/filter, opprett, rediger, slett, relasjonsvisning med navigering mellom entiteter - GraphBlock: SVG force-directed layout via traverse API, pan/zoom, drag noder, dobbeltklikk for å utforske - EntityAutocomplete: #-mention med debounced søk, tastaturnavigering, dropdown med typefarger og aliaser - Registrert entities block-type + kunnskapsgraf-side i seed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3f8ef65c5f
commit
6edd1fa091
5 changed files with 1160 additions and 12 deletions
|
|
@ -192,6 +192,16 @@ UPDATE workspaces SET settings = jsonb_set(
|
|||
{"id": "notes-1", "type": "notes", "title": "Show notes", "props": {"noteId": "a0000000-0000-0000-0000-000000000040"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "kunnskapsgraf",
|
||||
"title": "Kunnskapsgraf",
|
||||
"icon": "🕸️",
|
||||
"layout": "2-1",
|
||||
"blocks": [
|
||||
{"id": "graph-1", "type": "graph", "title": "Grafvisning"},
|
||||
{"id": "entities-1", "type": "entities", "title": "Entiteter"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "research",
|
||||
"title": "Research",
|
||||
|
|
@ -199,7 +209,7 @@ UPDATE workspaces SET settings = jsonb_set(
|
|||
"layout": "2-col",
|
||||
"blocks": [
|
||||
{"id": "research-1", "type": "research", "title": "Research-klipp"},
|
||||
{"id": "graph-1", "type": "graph", "title": "Kunnskapsgraf"}
|
||||
{"id": "entities-2", "type": "entities", "title": "Entiteter"}
|
||||
]
|
||||
}
|
||||
]'::jsonb
|
||||
|
|
|
|||
535
web/src/lib/blocks/EntitiesBlock.svelte
Normal file
535
web/src/lib/blocks/EntitiesBlock.svelte
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
|
||||
interface Entity {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
aliases: string[];
|
||||
avatar_url: string | null;
|
||||
edge_count?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
edge_id: string;
|
||||
source_id: string;
|
||||
target_id: string;
|
||||
relation_type: string;
|
||||
relation_label: string;
|
||||
connected_name: string;
|
||||
connected_type: string;
|
||||
connected_id: string;
|
||||
}
|
||||
|
||||
let entities = $state<Entity[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let query = $state('');
|
||||
let typeFilter = $state('');
|
||||
|
||||
// Opprett-skjema
|
||||
let showCreate = $state(false);
|
||||
let newName = $state('');
|
||||
let newType = $state('person');
|
||||
let newAliases = $state('');
|
||||
|
||||
// Detalj-visning
|
||||
let selected = $state<Entity | null>(null);
|
||||
let selectedEdges = $state<Edge[]>([]);
|
||||
let loadingEdges = $state(false);
|
||||
|
||||
// Redigering
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
let editType = $state('');
|
||||
let editAliases = $state('');
|
||||
|
||||
const entityTypes = ['person', 'organisasjon', 'sted', 'tema', 'konsept'];
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#3b82f6',
|
||||
organisasjon: '#f59e0b',
|
||||
sted: '#10b981',
|
||||
tema: '#8b5cf6',
|
||||
konsept: '#ec4899'
|
||||
};
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function debounceSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(fetchEntities, 200);
|
||||
}
|
||||
|
||||
async function fetchEntities() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
const res = await fetch(`/api/entities?${params}`);
|
||||
if (!res.ok) throw new Error('Feil ved henting');
|
||||
entities = await res.json();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntity() {
|
||||
if (!newName.trim()) return;
|
||||
const res = await fetch('/api/entities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
type: newType,
|
||||
aliases: newAliases.split(',').map(a => a.trim()).filter(Boolean)
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
newName = '';
|
||||
newAliases = '';
|
||||
showCreate = false;
|
||||
fetchEntities();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectEntity(entity: Entity) {
|
||||
selected = entity;
|
||||
editing = false;
|
||||
loadingEdges = true;
|
||||
try {
|
||||
const res = await fetch(`/api/entities/${entity.id}/edges`);
|
||||
selectedEdges = res.ok ? await res.json() : [];
|
||||
} catch {
|
||||
selectedEdges = [];
|
||||
} finally {
|
||||
loadingEdges = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!selected) return;
|
||||
editing = true;
|
||||
editName = selected.name;
|
||||
editType = selected.type;
|
||||
editAliases = selected.aliases.join(', ');
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!selected) return;
|
||||
const res = await fetch(`/api/entities/${selected.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName.trim(),
|
||||
type: editType,
|
||||
aliases: editAliases.split(',').map(a => a.trim()).filter(Boolean)
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
selected = { ...selected, ...updated };
|
||||
editing = false;
|
||||
fetchEntities();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntity() {
|
||||
if (!selected) return;
|
||||
const res = await fetch(`/api/entities/${selected.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
selected = null;
|
||||
fetchEntities();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEdge(edgeId: string) {
|
||||
const res = await fetch(`/api/graph/edges/${edgeId}`, { method: 'DELETE' });
|
||||
if (res.ok && selected) {
|
||||
selectEntity(selected);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(fetchEntities);
|
||||
</script>
|
||||
|
||||
{#if selected}
|
||||
<!-- Detalj-visning -->
|
||||
<div class="detail">
|
||||
<button type="button" class="back-btn" onclick={() => { selected = null; }}>
|
||||
← Tilbake
|
||||
</button>
|
||||
|
||||
{#if editing}
|
||||
<div class="edit-form">
|
||||
<input type="text" bind:value={editName} placeholder="Navn" class="input" />
|
||||
<select bind:value={editType} class="input">
|
||||
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
<input type="text" bind:value={editAliases} placeholder="Aliaser (kommaseparert)" class="input" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn primary" onclick={saveEdit}>Lagre</button>
|
||||
<button type="button" class="btn" onclick={() => { editing = false; }}>Avbryt</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="entity-header">
|
||||
<span class="type-badge" style:background={typeColors[selected.type] ?? '#8b92a5'}>
|
||||
{selected.type}
|
||||
</span>
|
||||
<h3 class="entity-name">{selected.name}</h3>
|
||||
{#if selected.aliases.length > 0}
|
||||
<div class="aliases">
|
||||
{#each selected.aliases as alias}
|
||||
<span class="alias">{alias}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" onclick={startEdit}>Rediger</button>
|
||||
<button type="button" class="btn danger" onclick={deleteEntity}>Slett</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edges-section">
|
||||
<h4 class="section-title">Relasjoner</h4>
|
||||
{#if loadingEdges}
|
||||
<p class="muted">Laster...</p>
|
||||
{:else if selectedEdges.length === 0}
|
||||
<p class="muted">Ingen relasjoner</p>
|
||||
{:else}
|
||||
{#each selectedEdges as edge (edge.edge_id)}
|
||||
<div class="edge-row">
|
||||
<span class="edge-label">{edge.relation_label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="edge-target"
|
||||
onclick={() => {
|
||||
const target = entities.find(e => e.id === edge.connected_id);
|
||||
if (target) selectEntity(target);
|
||||
}}
|
||||
>
|
||||
{edge.connected_name}
|
||||
</button>
|
||||
<span class="edge-type-badge" style:background={typeColors[edge.connected_type] ?? '#8b92a5'}>
|
||||
{edge.connected_type}
|
||||
</span>
|
||||
<button type="button" class="edge-delete" onclick={() => deleteEdge(edge.edge_id)}>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Liste-visning -->
|
||||
<div class="list-view">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Søk entiteter..."
|
||||
bind:value={query}
|
||||
oninput={debounceSearch}
|
||||
class="input search"
|
||||
/>
|
||||
<select bind:value={typeFilter} onchange={fetchEntities} class="input type-select">
|
||||
<option value="">Alle typer</option>
|
||||
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
<button type="button" class="btn primary" onclick={() => { showCreate = !showCreate; }}>+</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="create-form">
|
||||
<input type="text" bind:value={newName} placeholder="Navn" class="input" />
|
||||
<select bind:value={newType} class="input">
|
||||
{#each entityTypes as t}<option value={t}>{t}</option>{/each}
|
||||
</select>
|
||||
<input type="text" bind:value={newAliases} placeholder="Aliaser (kommaseparert)" class="input" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn primary" onclick={createEntity} disabled={!newName.trim()}>Opprett</button>
|
||||
<button type="button" class="btn" onclick={() => { showCreate = false; }}>Avbryt</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted center">Laster...</p>
|
||||
{:else if entities.length === 0}
|
||||
<p class="muted center">{query ? 'Ingen treff' : 'Ingen entiteter ennå'}</p>
|
||||
{:else}
|
||||
<div class="entity-list">
|
||||
{#each entities as entity (entity.id)}
|
||||
<button type="button" class="entity-row" onclick={() => selectEntity(entity)}>
|
||||
<span class="type-dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
||||
<span class="entity-row-name">{entity.name}</span>
|
||||
{#if entity.aliases.length > 0}
|
||||
<span class="entity-row-alias">{entity.aliases[0]}</span>
|
||||
{/if}
|
||||
<span class="entity-row-type">{entity.type}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error-msg">{error}</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list-view, .detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
select.input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #1e2235;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #8b92a5;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover { background: #2d3148; color: #e1e4e8; }
|
||||
.btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; }
|
||||
.btn.primary:hover { background: #2563eb; }
|
||||
.btn.primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn.danger { background: #dc2626; color: white; border-color: #dc2626; }
|
||||
.btn.danger:hover { background: #b91c1c; }
|
||||
|
||||
.create-form, .edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
background: #0f1117;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.entity-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #e1e4e8;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entity-row:hover {
|
||||
background: #1e2235;
|
||||
}
|
||||
|
||||
.type-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-row-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-row-alias {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.entity-row-type {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Detalj */
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.back-btn:hover { text-decoration: underline; }
|
||||
|
||||
.entity-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
font-size: 1.1rem;
|
||||
color: #e1e4e8;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aliases {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alias {
|
||||
background: #1e2235;
|
||||
color: #8b92a5;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.edges-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
color: #8b92a5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 0 0 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid #1e2235;
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edge-target {
|
||||
color: #3b82f6;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edge-target:hover { text-decoration: underline; }
|
||||
|
||||
.edge-type-badge {
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6rem;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edge-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b92a5;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.edge-delete:hover { color: #f87171; }
|
||||
|
||||
.muted { color: #8b92a5; font-size: 0.8rem; margin: 0; }
|
||||
.center { text-align: center; padding: 2rem 0; }
|
||||
.error-msg { color: #f87171; font-size: 0.75rem; padding: 0.25rem; }
|
||||
</style>
|
||||
|
|
@ -1,25 +1,388 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { props = {} }: { props?: Record<string, unknown> } = $props();
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
depth: number;
|
||||
node_type: string;
|
||||
name: string | null;
|
||||
entity_type: string | null;
|
||||
avatar_url: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
id: string;
|
||||
source_id: string;
|
||||
target_id: string;
|
||||
relation_type: string;
|
||||
relation_label: string;
|
||||
}
|
||||
|
||||
let nodes = $state<GraphNode[]>([]);
|
||||
let edges = $state<GraphEdge[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let selectedNodeId = $state<string | null>(null);
|
||||
let svgEl: SVGSVGElement;
|
||||
let animFrame: number;
|
||||
|
||||
// Pan/zoom
|
||||
let viewBox = $state({ x: -300, y: -300, w: 600, h: 600 });
|
||||
let dragging = $state<GraphNode | null>(null);
|
||||
let panning = $state(false);
|
||||
let panStart = { x: 0, y: 0 };
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#3b82f6',
|
||||
organisasjon: '#f59e0b',
|
||||
sted: '#10b981',
|
||||
tema: '#8b5cf6',
|
||||
konsept: '#ec4899'
|
||||
};
|
||||
|
||||
async function loadGraph(nodeId: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/graph/traverse/${nodeId}?depth=2`);
|
||||
if (!res.ok) throw new Error('Feil ved henting av graf');
|
||||
const data = await res.json();
|
||||
|
||||
// Initialiser posisjoner i sirkel
|
||||
const count = data.nodes.length;
|
||||
nodes = data.nodes.map((n: Omit<GraphNode, 'x' | 'y' | 'vx' | 'vy'>, i: number) => ({
|
||||
...n,
|
||||
x: Math.cos((2 * Math.PI * i) / count) * 120 * (n.depth + 1),
|
||||
y: Math.sin((2 * Math.PI * i) / count) * 120 * (n.depth + 1),
|
||||
vx: 0,
|
||||
vy: 0
|
||||
}));
|
||||
edges = data.edges;
|
||||
selectedNodeId = nodeId;
|
||||
startSimulation();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAndLoad() {
|
||||
if (!searchQuery.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/entities?q=${encodeURIComponent(searchQuery.trim())}&limit=1`);
|
||||
const results = await res.json();
|
||||
if (results.length > 0) {
|
||||
loadGraph(results[0].id);
|
||||
} else {
|
||||
error = 'Ingen treff';
|
||||
}
|
||||
} catch {
|
||||
error = 'Søkefeil';
|
||||
}
|
||||
}
|
||||
|
||||
// Enkel force-directed layout
|
||||
function startSimulation() {
|
||||
let iterations = 0;
|
||||
const maxIterations = 200;
|
||||
|
||||
function tick() {
|
||||
if (iterations >= maxIterations) return;
|
||||
iterations++;
|
||||
|
||||
const alpha = 1 - iterations / maxIterations;
|
||||
const k = alpha * 0.3;
|
||||
|
||||
// Repulsion mellom alle noder
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[j].x - nodes[i].x;
|
||||
const dy = nodes[j].y - nodes[i].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = (150 * 150) / dist;
|
||||
const fx = (dx / dist) * force * k;
|
||||
const fy = (dy / dist) * force * k;
|
||||
nodes[i].vx -= fx;
|
||||
nodes[i].vy -= fy;
|
||||
nodes[j].vx += fx;
|
||||
nodes[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction langs edges
|
||||
for (const edge of edges) {
|
||||
const source = nodes.find(n => n.id === edge.source_id);
|
||||
const target = nodes.find(n => n.id === edge.target_id);
|
||||
if (!source || !target) continue;
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const force = (dist - 120) * 0.01 * k;
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
source.vx += fx;
|
||||
source.vy += fy;
|
||||
target.vx -= fx;
|
||||
target.vy -= fy;
|
||||
}
|
||||
|
||||
// Center gravity
|
||||
for (const node of nodes) {
|
||||
node.vx -= node.x * 0.005 * k;
|
||||
node.vy -= node.y * 0.005 * k;
|
||||
}
|
||||
|
||||
// Apply velocity
|
||||
for (const node of nodes) {
|
||||
if (dragging && node.id === dragging.id) continue;
|
||||
node.vx *= 0.6;
|
||||
node.vy *= 0.6;
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
nodes = [...nodes];
|
||||
|
||||
if (alpha > 0.01) {
|
||||
animFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
cancelAnimationFrame(animFrame);
|
||||
tick();
|
||||
}
|
||||
|
||||
function handleNodeClick(node: GraphNode) {
|
||||
if (node.node_type === 'entitet') {
|
||||
loadGraph(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e: MouseEvent, node: GraphNode) {
|
||||
e.preventDefault();
|
||||
dragging = node;
|
||||
}
|
||||
|
||||
function handleSvgMouseMove(e: MouseEvent) {
|
||||
if (dragging && svgEl) {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const scaleX = viewBox.w / rect.width;
|
||||
const scaleY = viewBox.h / rect.height;
|
||||
dragging.x = viewBox.x + (e.clientX - rect.left) * scaleX;
|
||||
dragging.y = viewBox.y + (e.clientY - rect.top) * scaleY;
|
||||
nodes = [...nodes];
|
||||
} else if (panning) {
|
||||
const dx = (e.clientX - panStart.x) * (viewBox.w / svgEl.getBoundingClientRect().width);
|
||||
const dy = (e.clientY - panStart.y) * (viewBox.h / svgEl.getBoundingClientRect().height);
|
||||
viewBox = { ...viewBox, x: viewBox.x - dx, y: viewBox.y - dy };
|
||||
panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
function handleSvgMouseUp() {
|
||||
dragging = null;
|
||||
panning = false;
|
||||
}
|
||||
|
||||
function handleSvgMouseDown(e: MouseEvent) {
|
||||
if (e.target === svgEl || (e.target as Element).tagName === 'line') {
|
||||
panning = true;
|
||||
panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 1.1 : 0.9;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const mx = viewBox.x + ((e.clientX - rect.left) / rect.width) * viewBox.w;
|
||||
const my = viewBox.y + ((e.clientY - rect.top) / rect.height) * viewBox.h;
|
||||
const nw = viewBox.w * factor;
|
||||
const nh = viewBox.h * factor;
|
||||
viewBox = {
|
||||
x: mx - ((mx - viewBox.x) / viewBox.w) * nw,
|
||||
y: my - ((my - viewBox.y) / viewBox.h) * nh,
|
||||
w: nw,
|
||||
h: nh
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeColor(node: GraphNode): string {
|
||||
return typeColors[node.entity_type ?? ''] ?? '#8b92a5';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Last initial graf fra første entitet om ingen er valgt
|
||||
fetch('/api/entities?limit=1').then(r => r.json()).then(data => {
|
||||
if (data.length > 0) loadGraph(data[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => cancelAnimationFrame(animFrame));
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">🕸️</span>
|
||||
<p class="label">Graph</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
<div class="graph-container">
|
||||
<div class="graph-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Søk entitet..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') searchAndLoad(); }}
|
||||
class="input"
|
||||
/>
|
||||
<button type="button" class="btn" onclick={searchAndLoad}>Vis</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="center-msg">Laster graf...</div>
|
||||
{:else if error}
|
||||
<div class="center-msg error">{error}</div>
|
||||
{:else if nodes.length === 0}
|
||||
<div class="center-msg">Søk etter en entitet for å visualisere grafen</div>
|
||||
{:else}
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="{viewBox.x} {viewBox.y} {viewBox.w} {viewBox.h}"
|
||||
class="graph-svg"
|
||||
onmousemove={handleSvgMouseMove}
|
||||
onmouseup={handleSvgMouseUp}
|
||||
onmouseleave={handleSvgMouseUp}
|
||||
onmousedown={handleSvgMouseDown}
|
||||
onwheel={handleWheel}
|
||||
>
|
||||
<!-- Edges -->
|
||||
{#each edges as edge (edge.id)}
|
||||
{@const source = nodes.find(n => n.id === edge.source_id)}
|
||||
{@const target = nodes.find(n => n.id === edge.target_id)}
|
||||
{#if source && target}
|
||||
<line
|
||||
x1={source.x} y1={source.y}
|
||||
x2={target.x} y2={target.y}
|
||||
class="edge-line"
|
||||
/>
|
||||
<text
|
||||
x={(source.x + target.x) / 2}
|
||||
y={(source.y + target.y) / 2 - 6}
|
||||
class="edge-label"
|
||||
>{edge.relation_label}</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each nodes as node (node.id)}
|
||||
<g
|
||||
class="graph-node"
|
||||
class:selected={node.id === selectedNodeId}
|
||||
onmousedown={(e) => handleMouseDown(e, node)}
|
||||
ondblclick={() => handleNodeClick(node)}
|
||||
>
|
||||
<circle
|
||||
cx={node.x} cy={node.y} r={node.id === selectedNodeId ? 22 : 16}
|
||||
fill={getNodeColor(node)}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={node.x} y={node.y + 28}
|
||||
class="node-label"
|
||||
>{node.name ?? node.id.slice(0, 8)}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
.graph-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding-bottom: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input:focus { outline: none; border-color: #3b82f6; }
|
||||
.input::placeholder { color: #8b92a5; }
|
||||
|
||||
.btn {
|
||||
background: #1e2235;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #8b92a5;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:hover { background: #2d3148; color: #e1e4e8; }
|
||||
|
||||
.graph-svg {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.graph-svg:active { cursor: grabbing; }
|
||||
|
||||
.edge-line {
|
||||
stroke: #2d3148;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
fill: #8b92a5;
|
||||
font-size: 8px;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-node { cursor: pointer; }
|
||||
.graph-node:hover circle { opacity: 1; }
|
||||
.graph-node.selected circle { stroke: white; stroke-width: 2; }
|
||||
|
||||
.node-label {
|
||||
fill: #e1e4e8;
|
||||
font-size: 10px;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.center-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
color: #8b92a5;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.icon { font-size: 2rem; }
|
||||
.label { font-weight: 600; color: #e1e4e8; }
|
||||
.hint { font-size: 0.8rem; }
|
||||
|
||||
.center-msg.error { color: #f87171; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export const blockRegistry: Record<string, BlockMeta> = {
|
|||
icon: '🕸️',
|
||||
component: () => import('./GraphBlock.svelte')
|
||||
},
|
||||
entities: {
|
||||
label: 'Entiteter',
|
||||
icon: '#️⃣',
|
||||
component: () => import('./EntitiesBlock.svelte')
|
||||
},
|
||||
research: {
|
||||
label: 'Research',
|
||||
icon: '🔍',
|
||||
|
|
|
|||
235
web/src/lib/components/EntityAutocomplete.svelte
Normal file
235
web/src/lib/components/EntityAutocomplete.svelte
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* EntityAutocomplete — #-mention-komponent.
|
||||
* Aktiveres når brukeren skriver # i en textarea/input.
|
||||
* Søker i entities API og lar brukeren velge fra en dropdown.
|
||||
*
|
||||
* Bruk:
|
||||
* <EntityAutocomplete bind:text onMention={(entity) => ...} />
|
||||
*/
|
||||
interface Entity {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
text = $bindable(''),
|
||||
placeholder = '',
|
||||
onMention = (_entity: Entity) => {}
|
||||
}: {
|
||||
text?: string;
|
||||
placeholder?: string;
|
||||
onMention?: (entity: Entity) => void;
|
||||
} = $props();
|
||||
|
||||
let suggestions = $state<Entity[]>([]);
|
||||
let showDropdown = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let hashStart = $state(-1);
|
||||
let inputEl: HTMLTextAreaElement;
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
person: '#3b82f6',
|
||||
organisasjon: '#f59e0b',
|
||||
sted: '#10b981',
|
||||
tema: '#8b5cf6',
|
||||
konsept: '#ec4899'
|
||||
};
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleInput() {
|
||||
const pos = inputEl.selectionStart ?? 0;
|
||||
const textBefore = text.slice(0, pos);
|
||||
|
||||
// Finn siste # som ikke er escaped
|
||||
const hashMatch = textBefore.match(/#([^\s#]*)$/);
|
||||
if (hashMatch) {
|
||||
hashStart = pos - hashMatch[1].length - 1;
|
||||
const query = hashMatch[1];
|
||||
if (query.length >= 1) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => searchEntities(query), 150);
|
||||
} else {
|
||||
suggestions = [];
|
||||
showDropdown = true;
|
||||
}
|
||||
} else {
|
||||
showDropdown = false;
|
||||
suggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function searchEntities(query: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/entities?q=${encodeURIComponent(query)}&limit=8`);
|
||||
if (res.ok) {
|
||||
suggestions = await res.json();
|
||||
selectedIndex = 0;
|
||||
showDropdown = suggestions.length > 0;
|
||||
}
|
||||
} catch {
|
||||
suggestions = [];
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectSuggestion(entity: Entity) {
|
||||
const pos = inputEl.selectionStart ?? 0;
|
||||
const before = text.slice(0, hashStart);
|
||||
const after = text.slice(pos);
|
||||
text = `${before}#${entity.name}${after}`;
|
||||
showDropdown = false;
|
||||
suggestions = [];
|
||||
onMention(entity);
|
||||
|
||||
// Sett cursor etter mention
|
||||
const newPos = hashStart + 1 + entity.name.length;
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus();
|
||||
inputEl.setSelectionRange(newPos, newPos);
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown || suggestions.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % suggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (showDropdown && suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
selectSuggestion(suggestions[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="autocomplete-wrapper">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
bind:value={text}
|
||||
{placeholder}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={() => { setTimeout(() => { showDropdown = false; }, 200); }}
|
||||
rows="3"
|
||||
class="textarea"
|
||||
></textarea>
|
||||
|
||||
{#if showDropdown && suggestions.length > 0}
|
||||
<div class="dropdown">
|
||||
{#each suggestions as entity, i (entity.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="suggestion"
|
||||
class:selected={i === selectedIndex}
|
||||
onmousedown|preventDefault={() => selectSuggestion(entity)}
|
||||
>
|
||||
<span class="dot" style:background={typeColors[entity.type] ?? '#8b92a5'}></span>
|
||||
<span class="name">{entity.name}</span>
|
||||
{#if entity.aliases.length > 0}
|
||||
<span class="alias">{entity.aliases[0]}</span>
|
||||
{/if}
|
||||
<span class="type">{entity.type}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.autocomplete-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e1e4e8;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.textarea::placeholder {
|
||||
color: #8b92a5;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #161822;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #e1e4e8;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.suggestion:hover, .suggestion.selected {
|
||||
background: #1e2235;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alias {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.type {
|
||||
color: #8b92a5;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Reference in a new issue