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: 'Kommunikasjon', traits: ['chat', 'forum', 'comments', 'guest_input', 'announcements', 'polls', 'qa'] },
|
||||
{ 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: 'Tilgang & fellesskap', traits: ['membership', 'roles', 'invites', 'paywall', 'directory'] },
|
||||
{ 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 },
|
||||
mixer: { title: 'Mikser', icon: '🎚️', defaultWidth: 450, defaultHeight: 400 },
|
||||
orchestration: { title: 'Orkestrering', icon: '⚡', defaultWidth: 550, defaultHeight: 500 },
|
||||
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
|
||||
};
|
||||
|
||||
/** Default info for unknown traits */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
import StudioTrait from '$lib/components/traits/StudioTrait.svelte';
|
||||
import MixerTrait from '$lib/components/traits/MixerTrait.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 TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
||||
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
/** Traits with dedicated components */
|
||||
const knownTraits = new Set([
|
||||
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration'
|
||||
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'orchestration', 'mindmap'
|
||||
]);
|
||||
|
||||
/** Count of child nodes */
|
||||
|
|
@ -357,6 +358,8 @@
|
|||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'orchestration'}
|
||||
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{:else if trait === 'mindmap'}
|
||||
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<GenericTrait name={trait} config={traits[trait]} />
|
||||
|
|
@ -414,6 +417,8 @@
|
|||
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'orchestration'}
|
||||
<OrchestrationTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{:else if trait === 'mindmap'}
|
||||
<MindMapTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<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-
|
||||
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.
|
||||
> Påbegynt: 2026-03-18T19:26
|
||||
- [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.
|
||||
- [ ] 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).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue