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:
vegard 2026-03-18 19:32:17 +00:00
parent 461c3bfb79
commit e6b55543b5
5 changed files with 647 additions and 4 deletions

View 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">
&larr;
</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 &middot; 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>

View file

@ -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'] },

View file

@ -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 */

View file

@ -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]} />

View file

@ -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).