MindMap Svelte-komponent med radial/tree-layout (oppgave 27.1)
Ny MindMapTrait-komponent som viser noder og edges fra WebSocket-stores i radial eller hierarkisk tree-layout via d3-hierarchy. - Bygger trestruktur fra rotnode med konfigurerbar dybde (1-3 hopp) - Radial layout (trigonometrisk) og tree layout (hierarkisk) med toggle - Pan/zoom via d3-zoom, auto-fit ved oppstart - Klikk node = ny rot (med tilbake-historikk), dobbeltklikk = åpne - Edge-type-filtrering og visuell stil (farge, stiplet linje) - Privacy-markering på private noder - Responsivt: forenklet toolbar på mobil - Registrert som 'mindmap' trait i katalog og workspace-typer - Koblet inn i collection-side (desktop BlockShell + mobil tabs)
This commit is contained in:
parent
461c3bfb79
commit
e6b55543b5
5 changed files with 647 additions and 4 deletions
638
frontend/src/lib/components/traits/MindMapTrait.svelte
Normal file
638
frontend/src/lib/components/traits/MindMapTrait.svelte
Normal file
|
|
@ -0,0 +1,638 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { nodeStore, edgeStore, nodeVisibility } from '$lib/realtime';
|
||||||
|
import type { Node, Edge } from '$lib/realtime';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection?: Node;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
userId?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collection, config, userId, accessToken }: Props = $props();
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Config
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
const defaultDepth = $derived((config.default_depth as number) ?? 2);
|
||||||
|
const layoutMode = $derived((config.layout as string) ?? 'radial');
|
||||||
|
const allowedEdgeTypes = $derived(
|
||||||
|
(config.edge_types as string[]) ?? ['belongs_to', 'mentions', 'source_material', 'member_of', 'owner', 'part_of']
|
||||||
|
);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// State
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let svgEl: SVGSVGElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let rootId = $state<string | null>(null);
|
||||||
|
let depth = $state(2);
|
||||||
|
let hoveredNodeId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize depth from config
|
||||||
|
$effect(() => {
|
||||||
|
depth = defaultDepth;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial root to collection id
|
||||||
|
$effect(() => {
|
||||||
|
if (collection && !rootId) {
|
||||||
|
rootId = collection.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Node kind colors (consistent with graph page)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function nodeColor(kind: string): string {
|
||||||
|
return kindColors[kind] ?? '#9ca3af';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Build tree data from graph stores
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
id: string;
|
||||||
|
node: Node;
|
||||||
|
children: TreeNode[];
|
||||||
|
edgeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeData = $derived.by((): TreeNode | null => {
|
||||||
|
if (!rootId) return null;
|
||||||
|
const rootNode = nodeStore.get(rootId);
|
||||||
|
if (!rootNode) return null;
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
return buildTree(rootNode, visited, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildTree(node: Node, visited: Set<string>, currentDepth: number): TreeNode {
|
||||||
|
visited.add(node.id);
|
||||||
|
const treeNode: TreeNode = { id: node.id, node, children: [] };
|
||||||
|
|
||||||
|
if (currentDepth >= depth) return treeNode;
|
||||||
|
|
||||||
|
// Get all edges connected to this node (both directions)
|
||||||
|
const outEdges = edgeStore.bySource(node.id);
|
||||||
|
const inEdges = edgeStore.byTarget(node.id);
|
||||||
|
const allEdges = [...outEdges, ...inEdges];
|
||||||
|
|
||||||
|
for (const edge of allEdges) {
|
||||||
|
if (!allowedEdgeTypes.includes(edge.edgeType)) continue;
|
||||||
|
|
||||||
|
const otherId = edge.sourceId === node.id ? edge.targetId : edge.sourceId;
|
||||||
|
if (visited.has(otherId)) continue;
|
||||||
|
|
||||||
|
const otherNode = nodeStore.get(otherId);
|
||||||
|
if (!otherNode) continue;
|
||||||
|
|
||||||
|
// Check visibility
|
||||||
|
const vis = nodeVisibility(otherNode, userId);
|
||||||
|
if (vis === 'hidden') continue;
|
||||||
|
|
||||||
|
const child = buildTree(otherNode, visited, currentDepth + 1);
|
||||||
|
child.edgeType = edge.edgeType;
|
||||||
|
treeNode.children.push(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort children by title for stable layout
|
||||||
|
treeNode.children.sort((a, b) =>
|
||||||
|
(a.node.title ?? '').localeCompare(b.node.title ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return treeNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count connected edges for tooltip
|
||||||
|
function edgeCount(nodeId: string): number {
|
||||||
|
return edgeStore.bySource(nodeId).length + edgeStore.byTarget(nodeId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// D3 layout + SVG rendering
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | null = null;
|
||||||
|
|
||||||
|
function renderMindMap() {
|
||||||
|
if (!svgEl || !treeData) return;
|
||||||
|
|
||||||
|
d3.select(svgEl).selectAll('*').remove();
|
||||||
|
|
||||||
|
const width = svgEl.clientWidth || 600;
|
||||||
|
const height = svgEl.clientHeight || 400;
|
||||||
|
const svg = d3.select(svgEl);
|
||||||
|
|
||||||
|
// Zoom container
|
||||||
|
const g = svg.append('g');
|
||||||
|
|
||||||
|
zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
|
||||||
|
.scaleExtent([0.1, 4])
|
||||||
|
.on('zoom', (event) => {
|
||||||
|
g.attr('transform', event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.call(zoomBehavior);
|
||||||
|
|
||||||
|
// Build d3 hierarchy
|
||||||
|
const root = d3.hierarchy(treeData, d => d.children);
|
||||||
|
|
||||||
|
if (layoutMode === 'radial') {
|
||||||
|
renderRadial(g, root, width, height);
|
||||||
|
} else {
|
||||||
|
renderTree(g, root, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fit after layout
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const bounds = (g.node() as SVGGElement)?.getBBox();
|
||||||
|
if (!bounds || bounds.width === 0) return;
|
||||||
|
const padding = 60;
|
||||||
|
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(400).call(
|
||||||
|
zoomBehavior!.transform,
|
||||||
|
d3.zoomIdentity.translate(tx, ty).scale(scale)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRadial(
|
||||||
|
g: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||||
|
root: d3.HierarchyNode<TreeNode>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const nodeCount = root.descendants().length;
|
||||||
|
const radius = Math.max(120, Math.min(300, nodeCount * 30));
|
||||||
|
|
||||||
|
const treeLayout = d3.tree<TreeNode>()
|
||||||
|
.size([2 * Math.PI, radius])
|
||||||
|
.separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth || 1);
|
||||||
|
|
||||||
|
treeLayout(root);
|
||||||
|
|
||||||
|
// Convert polar to cartesian
|
||||||
|
function radialPoint(d: d3.HierarchyPointNode<TreeNode>): [number, number] {
|
||||||
|
const angle = d.x - Math.PI / 2; // rotate so root is at top
|
||||||
|
const r = d.y;
|
||||||
|
return [r * Math.cos(angle), r * Math.sin(angle)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
g.append('g')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.selectAll('path')
|
||||||
|
.data(root.links())
|
||||||
|
.join('path')
|
||||||
|
.attr('stroke', d => edgeStroke(d.target.data.edgeType))
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('stroke-opacity', 0.5)
|
||||||
|
.attr('stroke-dasharray', d => edgeDash(d.target.data.edgeType))
|
||||||
|
.attr('d', d => {
|
||||||
|
const [sx, sy] = radialPoint(d.source as d3.HierarchyPointNode<TreeNode>);
|
||||||
|
const [tx, ty] = radialPoint(d.target as d3.HierarchyPointNode<TreeNode>);
|
||||||
|
return `M${sx},${sy}L${tx},${ty}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
const nodeGroups = g.append('g')
|
||||||
|
.selectAll<SVGGElement, d3.HierarchyPointNode<TreeNode>>('g')
|
||||||
|
.data(root.descendants() as d3.HierarchyPointNode<TreeNode>[])
|
||||||
|
.join('g')
|
||||||
|
.attr('transform', d => {
|
||||||
|
const [x, y] = radialPoint(d);
|
||||||
|
return `translate(${x},${y})`;
|
||||||
|
})
|
||||||
|
.attr('cursor', 'pointer');
|
||||||
|
|
||||||
|
addNodeElements(nodeGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(
|
||||||
|
g: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||||
|
root: d3.HierarchyNode<TreeNode>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const nodeCount = root.descendants().length;
|
||||||
|
const treeWidth = Math.max(300, nodeCount * 40);
|
||||||
|
const treeHeight = Math.max(200, root.height * 120);
|
||||||
|
|
||||||
|
const treeLayout = d3.tree<TreeNode>()
|
||||||
|
.size([treeWidth, treeHeight]);
|
||||||
|
|
||||||
|
treeLayout(root);
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
g.append('g')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.selectAll('path')
|
||||||
|
.data(root.links())
|
||||||
|
.join('path')
|
||||||
|
.attr('stroke', d => edgeStroke(d.target.data.edgeType))
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('stroke-opacity', 0.5)
|
||||||
|
.attr('stroke-dasharray', d => edgeDash(d.target.data.edgeType))
|
||||||
|
.attr('d', d => {
|
||||||
|
const s = d.source as d3.HierarchyPointNode<TreeNode>;
|
||||||
|
const t = d.target as d3.HierarchyPointNode<TreeNode>;
|
||||||
|
return `M${s.x},${s.y}C${s.x},${(s.y + t.y) / 2} ${t.x},${(s.y + t.y) / 2} ${t.x},${t.y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw nodes
|
||||||
|
const nodeGroups = g.append('g')
|
||||||
|
.selectAll<SVGGElement, d3.HierarchyPointNode<TreeNode>>('g')
|
||||||
|
.data(root.descendants() as d3.HierarchyPointNode<TreeNode>[])
|
||||||
|
.join('g')
|
||||||
|
.attr('transform', d => `translate(${(d as d3.HierarchyPointNode<TreeNode>).x},${(d as d3.HierarchyPointNode<TreeNode>).y})`)
|
||||||
|
.attr('cursor', 'pointer');
|
||||||
|
|
||||||
|
addNodeElements(nodeGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNodeElements(
|
||||||
|
nodeGroups: d3.Selection<SVGGElement, d3.HierarchyPointNode<TreeNode>, SVGGElement, unknown>
|
||||||
|
) {
|
||||||
|
// Circle
|
||||||
|
nodeGroups.append('circle')
|
||||||
|
.attr('r', d => d.depth === 0 ? 14 : 9)
|
||||||
|
.attr('fill', d => nodeColor(d.data.node.nodeKind))
|
||||||
|
.attr('stroke', d => d.depth === 0 ? '#1f2937' : '#fff')
|
||||||
|
.attr('stroke-width', d => d.depth === 0 ? 3 : 2);
|
||||||
|
|
||||||
|
// Privacy marker
|
||||||
|
nodeGroups.filter(d => d.data.node.visibility === 'private')
|
||||||
|
.append('text')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', '-1.2em')
|
||||||
|
.attr('font-size', '10px')
|
||||||
|
.text('\uD83D\uDD12');
|
||||||
|
|
||||||
|
// Label
|
||||||
|
nodeGroups.append('text')
|
||||||
|
.attr('dy', d => d.depth === 0 ? 28 : 22)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', d => d.depth === 0 ? '13px' : '11px')
|
||||||
|
.attr('font-weight', d => d.depth === 0 ? '600' : '400')
|
||||||
|
.attr('fill', '#374151')
|
||||||
|
.text(d => {
|
||||||
|
const t = d.data.node.title || 'Uten tittel';
|
||||||
|
return t.length > 22 ? t.slice(0, 20) + '\u2026' : t;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interaction
|
||||||
|
nodeGroups
|
||||||
|
.on('click', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
// Single click = new root
|
||||||
|
rootId = d.data.id;
|
||||||
|
})
|
||||||
|
.on('dblclick', (event, d) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
// Double click = open in editor (navigate)
|
||||||
|
window.location.href = `/node/${d.data.id}`;
|
||||||
|
})
|
||||||
|
.on('mouseenter', (_event, d) => {
|
||||||
|
hoveredNodeId = d.data.id;
|
||||||
|
})
|
||||||
|
.on('mouseleave', () => {
|
||||||
|
hoveredNodeId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tooltip (title element)
|
||||||
|
nodeGroups.append('title')
|
||||||
|
.text(d => {
|
||||||
|
const n = d.data.node;
|
||||||
|
const kind = kindLabels[n.nodeKind] ?? n.nodeKind;
|
||||||
|
const edges = edgeCount(n.id);
|
||||||
|
return `${n.title || 'Uten tittel'}\n${kind} \u00b7 ${edges} koblinger`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Edge styling
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function edgeStroke(edgeType?: string): string {
|
||||||
|
if (edgeType === 'mentions') return '#8b5cf6';
|
||||||
|
if (edgeType === 'belongs_to') return '#9ca3af';
|
||||||
|
if (edgeType === 'owner') return '#3b82f6';
|
||||||
|
if (edgeType === 'member_of') return '#10b981';
|
||||||
|
if (edgeType === 'source_material') return '#f59e0b';
|
||||||
|
return '#d1d5db';
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeDash(edgeType?: string): string {
|
||||||
|
if (edgeType === 'mentions') return '6,3';
|
||||||
|
if (edgeType === 'source_material') return '4,2';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Re-render on data/state change
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (treeData && svgEl) {
|
||||||
|
renderMindMap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (treeData && svgEl) renderMindMap();
|
||||||
|
});
|
||||||
|
observer.observe(containerEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Navigation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let history = $state<string[]>([]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
const prev = history[history.length - 1];
|
||||||
|
history = history.slice(0, -1);
|
||||||
|
rootId = prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track root changes for back navigation
|
||||||
|
let previousRoot = $state<string | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (rootId && previousRoot && rootId !== previousRoot) {
|
||||||
|
history = [...history, previousRoot];
|
||||||
|
}
|
||||||
|
previousRoot = rootId;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetRoot() {
|
||||||
|
if (collection) {
|
||||||
|
rootId = collection.id;
|
||||||
|
history = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Layout toggle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let currentLayout = $state<'radial' | 'tree'>('radial');
|
||||||
|
$effect(() => {
|
||||||
|
currentLayout = layoutMode === 'tree' ? 'tree' : 'radial';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override layoutMode for rendering
|
||||||
|
const effectiveLayout = $derived(currentLayout);
|
||||||
|
|
||||||
|
// Node count for display
|
||||||
|
const nodeCount = $derived(treeData ? countNodes(treeData) : 0);
|
||||||
|
|
||||||
|
function countNodes(tree: TreeNode): number {
|
||||||
|
let count = 1;
|
||||||
|
for (const child of tree.children) {
|
||||||
|
count += countNodes(child);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root node title
|
||||||
|
const rootTitle = $derived(treeData?.node.title ?? 'Tankekart');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mindmap-container" bind:this={containerEl}>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="mindmap-toolbar">
|
||||||
|
<div class="mindmap-toolbar-left">
|
||||||
|
{#if history.length > 0}
|
||||||
|
<button class="mindmap-btn" onclick={goBack} title="Tilbake">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if rootId !== collection?.id}
|
||||||
|
<button class="mindmap-btn" onclick={resetRoot} title="Tilbake til rot">
|
||||||
|
Rot
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<span class="mindmap-title">{rootTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mindmap-toolbar-right">
|
||||||
|
<span class="mindmap-count">{nodeCount} noder</span>
|
||||||
|
<label class="mindmap-depth-label">
|
||||||
|
Dybde:
|
||||||
|
<select bind:value={depth} class="mindmap-depth-select">
|
||||||
|
<option value={1}>1</option>
|
||||||
|
<option value={2}>2</option>
|
||||||
|
<option value={3}>3</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="mindmap-btn {currentLayout === 'radial' ? 'mindmap-btn-active' : ''}"
|
||||||
|
onclick={() => { currentLayout = 'radial'; }}
|
||||||
|
title="Radial layout"
|
||||||
|
>
|
||||||
|
Radial
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mindmap-btn {currentLayout === 'tree' ? 'mindmap-btn-active' : ''}"
|
||||||
|
onclick={() => { currentLayout = 'tree'; }}
|
||||||
|
title="Tre-layout"
|
||||||
|
>
|
||||||
|
Tre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG canvas -->
|
||||||
|
<div class="mindmap-svg-wrap">
|
||||||
|
{#if !treeData}
|
||||||
|
<div class="mindmap-empty">
|
||||||
|
<p>Ingen data tilgjengelig.</p>
|
||||||
|
<p class="mindmap-empty-sub">Velg en node som rot for tankekartet.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<svg bind:this={svgEl} class="mindmap-svg"></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="mindmap-legend">
|
||||||
|
<span class="mindmap-legend-hint">Klikk = ny rot · Dobbeltklikk = åpne</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mindmap-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
background: #fafbfc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-toolbar-left,
|
||||||
|
.mindmap-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-btn-active {
|
||||||
|
background: #eef2ff;
|
||||||
|
border-color: #818cf8;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-depth-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-depth-select {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: white;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-svg-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-empty-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #d1d5db;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-legend {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-legend-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: simplify */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mindmap-toolbar {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-toolbar-right {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-title {
|
||||||
|
max-width: 120px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -14,7 +14,7 @@ export const traitCatalog: TraitCategory[] = [
|
||||||
{ label: 'Lyd & video', traits: ['podcast', 'recording', 'transcription', 'tts', 'clips', 'playlist'] },
|
{ label: 'Lyd & video', traits: ['podcast', 'recording', 'transcription', 'tts', 'clips', 'playlist'] },
|
||||||
{ label: 'Kommunikasjon', traits: ['chat', 'forum', 'comments', 'guest_input', 'announcements', 'polls', 'qa'] },
|
{ label: 'Kommunikasjon', traits: ['chat', 'forum', 'comments', 'guest_input', 'announcements', 'polls', 'qa'] },
|
||||||
{ label: 'Organisering', traits: ['kanban', 'calendar', 'timeline', 'table', 'gallery', 'bookmarks', 'tags'] },
|
{ label: 'Organisering', traits: ['kanban', 'calendar', 'timeline', 'table', 'gallery', 'bookmarks', 'tags'] },
|
||||||
{ label: 'Kunnskap', traits: ['knowledge_graph', 'wiki', 'glossary', 'faq', 'bibliography'] },
|
{ label: 'Kunnskap', traits: ['knowledge_graph', 'mindmap', 'wiki', 'glossary', 'faq', 'bibliography'] },
|
||||||
{ label: 'Automatisering & AI', traits: ['auto_tag', 'auto_summarize', 'digest', 'bridge', 'moderation'] },
|
{ label: 'Automatisering & AI', traits: ['auto_tag', 'auto_summarize', 'digest', 'bridge', 'moderation'] },
|
||||||
{ label: 'Tilgang & fellesskap', traits: ['membership', 'roles', 'invites', 'paywall', 'directory'] },
|
{ label: 'Tilgang & fellesskap', traits: ['membership', 'roles', 'invites', 'paywall', 'directory'] },
|
||||||
{ label: 'Ekstern integrasjon', traits: ['webhook', 'import', 'export', 'ical_sync'] },
|
{ label: 'Ekstern integrasjon', traits: ['webhook', 'import', 'export', 'ical_sync'] },
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
|
||||||
studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 },
|
studio: { title: 'Studio', icon: '🎛️', defaultWidth: 550, defaultHeight: 450 },
|
||||||
mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 },
|
mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 },
|
||||||
orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 },
|
orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 },
|
||||||
|
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default info for unknown traits */
|
/** Default info for unknown traits */
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
|
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
|
||||||
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
|
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
|
||||||
import OrchestrationTrait from '$lib/components/traits/OrchestrationTrait.svelte';
|
import OrchestrationTrait from '$lib/components/traits/OrchestrationTrait.svelte';
|
||||||
|
import MindMapTrait from '$lib/components/traits/MindMapTrait.svelte';
|
||||||
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
||||||
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
||||||
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||||
|
|
@ -71,7 +72,7 @@
|
||||||
/** Traits with dedicated components */
|
/** Traits with dedicated components */
|
||||||
const knownTraits = new Set([
|
const knownTraits = new Set([
|
||||||
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||||
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration'
|
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration', 'mindmap'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Count of child nodes */
|
/** Count of child nodes */
|
||||||
|
|
@ -357,6 +358,8 @@
|
||||||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||||
{:else if trait === 'orchestration'}
|
{:else if trait === 'orchestration'}
|
||||||
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
|
{:else if trait === 'mindmap'}
|
||||||
|
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<GenericTrait name={trait} config={traits[trait]} />
|
<GenericTrait name={trait} config={traits[trait]} />
|
||||||
|
|
@ -414,6 +417,8 @@
|
||||||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||||
{:else if trait === 'orchestration'}
|
{:else if trait === 'orchestration'}
|
||||||
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
|
{:else if trait === 'mindmap'}
|
||||||
|
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<GenericTrait name={trait} config={traits[trait]} />
|
<GenericTrait name={trait} config={traits[trait]} />
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -361,8 +361,7 @@ Ref: `docs/features/tankekart.md`. Tankekart-panel som viser noder og edges
|
||||||
i radial layout med en rotnode i sentrum. Ingen ny backend — ren frontend-
|
i radial layout med en rotnode i sentrum. Ingen ny backend — ren frontend-
|
||||||
visning av eksisterende grafdata.
|
visning av eksisterende grafdata.
|
||||||
|
|
||||||
- [~] 27.1 MindMap Svelte-komponent: radial/tree-layout av noder rundt en rotnode. Hent relaterte noder (1-2 hopp) via WebSocket. d3-hierarchy eller trigonometri for layout. Pan/zoom via canvas-primitiv. Klikk node = ny rot, dobbeltklikk = åpne i editor.
|
- [x] 27.1 MindMap Svelte-komponent: radial/tree-layout av noder rundt en rotnode. Hent relaterte noder (1-2 hopp) via WebSocket. d3-hierarchy eller trigonometri for layout. Pan/zoom via canvas-primitiv. Klikk node = ny rot, dobbeltklikk = åpne i editor.
|
||||||
> Påbegynt: 2026-03-18T19:26
|
|
||||||
- [ ] 27.2 BlockShell-panel: MindMap som BlockShell-panel i arbeidsflaten med fullskjerm, resize, drag-handle. Rotnode fra kontekst-header. Responsivt.
|
- [ ] 27.2 BlockShell-panel: MindMap som BlockShell-panel i arbeidsflaten med fullskjerm, resize, drag-handle. Rotnode fra kontekst-header. Responsivt.
|
||||||
- [ ] 27.3 MindMap-trait: `mindmap`-trait for samlingsnoder. Vises i trait-velger ved opprettelse. Konfigurasjon: default dybde (1-3 hopp), layout-stil (radial/tree).
|
- [ ] 27.3 MindMap-trait: `mindmap`-trait for samlingsnoder. Vises i trait-velger ved opprettelse. Konfigurasjon: default dybde (1-3 hopp), layout-stil (radial/tree).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue