Перейти к основному содержимому

MindMap

Визуализация структуры курса/урока с помощью Skia.

Структура

src/features/mindmap/
├── api/
│ └── queries.ts # fetchCourseMindMap(), fetchLessonMindMap()
├── hooks/
│ ├── use-mindmap-layout.ts # Расчёт позиций узлов
│ ├── use-course-mindmap.ts
│ └── use-lesson-mindmap.ts
├── components/
│ ├── mindmap-canvas.tsx # Основной Skia canvas
│ ├── mindmap-node.tsx # Узел
│ ├── mindmap-edge.tsx # Связь
│ ├── course-mindmap.tsx # Контейнер для курса
│ ├── lesson-mindmap.tsx # Контейнер для урока
│ ├── mindmap-controls.tsx # Zoom/pan контролы
│ └── legend-item.tsx # Легенда
├── utils/
│ ├── layout.ts # Алгоритм расположения
│ └── transform.ts # Трансформации
├── types.ts
└── index.ts

Доменная модель

MindMapData
├── nodes: MindMapNode[]
│ ├── id: string
│ ├── label: string
│ ├── type: 'root' | 'branch' | 'leaf'
│ ├── color: string
│ └── position?: { x: number, y: number }
└── edges: MindMapEdge[]
├── id: string
├── source: string (node id)
└── target: string (node id)

Skia Canvas

Используем @shopify/react-native-skia:

// src/features/mindmap/components/mindmap-canvas.tsx
import { Canvas, Group } from '@shopify/react-native-skia';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle
} from 'react-native-reanimated';

export function MindMapCanvas({ data }) {
const scale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);

const pinchGesture = Gesture.Pinch()
.onUpdate((e) => {
scale.value = Math.min(Math.max(e.scale, 0.5), 3);
});

const panGesture = Gesture.Pan()
.onUpdate((e) => {
translateX.value += e.changeX;
translateY.value += e.changeY;
});

const gesture = Gesture.Simultaneous(pinchGesture, panGesture);

return (
<GestureDetector gesture={gesture}>
<Canvas style={{ flex: 1 }}>
<Group
transform={[
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
]}
>
{data.edges.map(edge => (
<MindMapEdge key={edge.id} edge={edge} nodes={data.nodes} />
))}
{data.nodes.map(node => (
<MindMapNode key={node.id} node={node} />
))}
</Group>
</Canvas>
</GestureDetector>
);
}

Layout Algorithm

Автоматическое расположение узлов:

// src/features/mindmap/utils/layout.ts
export function calculateLayout(data: MindMapData): MindMapData {
const root = data.nodes.find(n => n.type === 'root');
if (!root) return data;

const positioned = new Map<string, Position>();
positioned.set(root.id, { x: 0, y: 0 });

// BFS для расположения узлов по уровням
const queue = [root.id];
let level = 0;

while (queue.length > 0) {
const levelSize = queue.length;
const levelNodes: string[] = [];

for (let i = 0; i < levelSize; i++) {
const nodeId = queue.shift()!;
levelNodes.push(nodeId);

// Найти дочерние узлы
const children = data.edges
.filter(e => e.source === nodeId)
.map(e => e.target);

queue.push(...children);
}

// Расположить узлы уровня
const spacing = 150;
const startY = -(levelNodes.length - 1) * spacing / 2;

levelNodes.forEach((nodeId, index) => {
positioned.set(nodeId, {
x: level * 200,
y: startY + index * spacing,
});
});

level++;
}

return {
...data,
nodes: data.nodes.map(node => ({
...node,
position: positioned.get(node.id) || { x: 0, y: 0 },
})),
};
}

Zustand Store

mindmap-store.ts — ephemeral state для pan/zoom:

interface MindMapStore {
scale: number;
translateX: number;
translateY: number;
setScale: (scale: number) => void;
setTranslate: (x: number, y: number) => void;
reset: () => void;
}

Hooks

useCourseMindMap

function CourseDetail({ courseId }) {
const { data: mindmap, isLoading } = useCourseMindMap(courseId);

return (
<View className="h-64">
<MindMapCanvas data={mindmap} />
<MindMapControls />
</View>
);
}

useMindMapLayout

function MindMapCanvas({ data }) {
const layoutedData = useMindMapLayout(data);

return (
<Canvas>
{layoutedData.nodes.map(node => (
<MindMapNode key={node.id} node={node} />
))}
</Canvas>
);
}

Цветовая кодировка

Тип узлаЦветОписание
rootOrangeНазвание курса/урока
branchVioletМодули/секции
leafGreenУроки/понятия

Query Keys

['mindmap', 'course', courseId]
['mindmap', 'lesson', lessonId]