diff --git a/.changeset/lemon-geckos-run.md b/.changeset/lemon-geckos-run.md
new file mode 100644
index 00000000..a5a1c62d
--- /dev/null
+++ b/.changeset/lemon-geckos-run.md
@@ -0,0 +1,10 @@
+---
+"@hyperbook/web-component-learningmap": minor
+"@hyperbook/markdown": minor
+"@hyperbook/types": minor
+"@hyperbook/fs": minor
+"hyperbook": minor
+"hyperbook-studio": minor
+---
+
+Vastly improved learningmap element
diff --git a/packages/fs/src/hyperbook.ts b/packages/fs/src/hyperbook.ts
index 80fcc717..fa1eec60 100644
--- a/packages/fs/src/hyperbook.ts
+++ b/packages/fs/src/hyperbook.ts
@@ -177,6 +177,7 @@ export const getPagesAndSections = async (
}
const page: HyperbookPage = {
...data,
+ path: file.path,
};
const repo = makeRepoLink(hyperbook.repo, file);
if (repo) {
diff --git a/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap b/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap
index 8072bccd..f0633651 100644
--- a/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap
+++ b/packages/fs/tests/__snapshots__/hyperbook.test.ts.snap
@@ -5,16 +5,37 @@ exports[`hyperbook > should get navigation 1`] = `
"current": {
"href": "/paradigms",
"name": "Paradigms",
+ "path": {
+ "absolute": "single-hyperbook/book/paradigms.md",
+ "directory": "",
+ "href": "/paradigms",
+ "permalink": null,
+ "relative": "paradigms.md",
+ },
"repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/paradigms.md",
},
"next": {
"href": "/hyperbook-test",
"name": "hyperbook-test",
+ "path": {
+ "absolute": "single-hyperbook/book/hyperbook-test.md",
+ "directory": "",
+ "href": "/hyperbook-test",
+ "permalink": null,
+ "relative": "hyperbook-test.md",
+ },
"repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/hyperbook-test.md",
},
"previous": {
"href": "/",
"name": "Home",
+ "path": {
+ "absolute": "single-hyperbook/book/index.md",
+ "directory": "",
+ "href": "/",
+ "permalink": null,
+ "relative": "index.md",
+ },
"repo": "https://github.com/openpatch/hyperbook/edit/main/website/en/book/index.md",
},
}
diff --git a/packages/fs/tests/hyperbook.test.ts b/packages/fs/tests/hyperbook.test.ts
index 784b5fa9..dfea4cbb 100644
--- a/packages/fs/tests/hyperbook.test.ts
+++ b/packages/fs/tests/hyperbook.test.ts
@@ -1,8 +1,17 @@
import path from "path";
import { describe, it, expect } from "vitest";
import { hyperbook, vfile } from "../src";
+import { HyperbookPage } from "@hyperbook/types/dist";
describe("hyperbook", () => {
+ const relative = (s: string) =>
+ path.relative(path.join(__dirname, "fixtures"), s);
+ const makeFileRelative = (p: HyperbookPage) => {
+ return {
+ ...p,
+ path: { ...p.path, absolute: relative(p.path?.absolute || "") },
+ } as HyperbookPage;
+ };
it("should get navigation", async () => {
let hyperbookPath = path.join(__dirname, "fixtures", "single-hyperbook");
let files = await vfile.list(hyperbookPath);
@@ -17,7 +26,10 @@ describe("hyperbook", () => {
pagesAndSections.sections,
pagesAndSections.pages,
);
- const navigation = await hyperbook.getNavigationForFile(pageList, current);
+ const navigation = await hyperbook.getNavigationForFile(
+ pageList.map(makeFileRelative),
+ current,
+ );
expect(navigation).toMatchSnapshot();
});
});
diff --git a/packages/markdown/assets/directive-learningmap/client.js b/packages/markdown/assets/directive-learningmap/client.js
index 8821f74d..fff82dd3 100644
--- a/packages/markdown/assets/directive-learningmap/client.js
+++ b/packages/markdown/assets/directive-learningmap/client.js
@@ -6,29 +6,14 @@ hyperbook.learningmap = (function () {
const map = elem.getElementsByTagName("hyperbook-learningmap")[0];
if (map) {
const result = await store.learningmap.get(elem.id);
- if (result && result.nodeState) {
- map.nodeState = result.nodeState;
- map.x = result.x || 0;
- map.y = result.y || 0;
- map.zoom = result.zoom || 1;
+ if (result) {
+ map.initialState = result;
}
map.addEventListener("change", function (event) {
store.learningmap
.update(elem.id, {
- nodeState: event.detail,
- })
- .then((updated) => {
- if (updated == 0) {
- store.learningmap.put({
- id: elem.id,
- nodeState: event.detail,
- });
- }
- });
- });
- map.addEventListener("viewport-change", function (event) {
- store.learningmap
- .update(elem.id, {
+ id: elem.id,
+ nodes: event.detail.nodes,
x: event.detail.x,
y: event.detail.y,
zoom: event.detail.zoom,
@@ -37,6 +22,7 @@ hyperbook.learningmap = (function () {
if (updated == 0) {
store.learningmap.put({
id: elem.id,
+ nodes: event.detail.nodes,
x: event.detail.x,
y: event.detail.y,
zoom: event.detail.zoom,
diff --git a/packages/markdown/assets/store.js b/packages/markdown/assets/store.js
index bc719a8c..9e7b60f3 100644
--- a/packages/markdown/assets/store.js
+++ b/packages/markdown/assets/store.js
@@ -26,7 +26,7 @@ store.version(1).stores({
webide: `id,html,css,js`,
h5p: `id,userData`,
geogebra: `id,state`,
- learningmap: `id,nodeState,x,y,zoom`,
+ learningmap: `id,nodes,x,y,zoom`,
});
var sqlIdeDB = new Dexie("SQL-IDE");
sqlIdeDB.version(0.1).stores({
diff --git a/packages/markdown/src/helper.ts b/packages/markdown/src/helper.ts
new file mode 100644
index 00000000..ef78e170
--- /dev/null
+++ b/packages/markdown/src/helper.ts
@@ -0,0 +1,25 @@
+import { HyperbookContext } from "@hyperbook/types";
+import path from "path";
+import fs from "fs";
+
+export const readFile = (src: string, ctx: HyperbookContext) => {
+ let srcFile = null;
+ try {
+ srcFile = fs.readFileSync(path.join(ctx.root, "public", src), "utf-8");
+ } catch (e) {
+ try {
+ srcFile = fs.readFileSync(path.join(ctx.root, "book", src), "utf-8");
+ } catch (e) {
+ srcFile = fs.readFileSync(
+ path.join(
+ ctx.root,
+ "book",
+ ctx.navigation.current?.path?.directory || "",
+ src,
+ ),
+ "utf-8",
+ );
+ }
+ }
+ return srcFile;
+};
diff --git a/packages/markdown/src/rehypeDirectiveP5.ts b/packages/markdown/src/rehypeDirectiveP5.ts
index 4d31ef0d..21ab71d3 100644
--- a/packages/markdown/src/rehypeDirectiveP5.ts
+++ b/packages/markdown/src/rehypeDirectiveP5.ts
@@ -3,7 +3,6 @@
//
import { HyperbookContext } from "@hyperbook/types";
import { Root } from "mdast";
-import fs from "fs";
import path from "path";
import { visit } from "unist-util-visit";
import { VFile } from "vfile";
@@ -16,6 +15,7 @@ import {
import { toText } from "./mdastUtilToText";
import hash from "./objectHash";
import { i18n } from "./i18n";
+import { readFile } from "./helper";
interface CodeBundle {
js?: string;
@@ -94,10 +94,7 @@ ${(code.scripts ? [cdnLibraryUrl, ...code.scripts] : []).map((src) => `
+
+
+
Learningmap Editor Test
+
+
-
+
+
diff --git a/packages/web-component-learningmap/map.html b/packages/web-component-learningmap/map.html
new file mode 100644
index 00000000..1cc6bda7
--- /dev/null
+++ b/packages/web-component-learningmap/map.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+ Learningmap Editor Test
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-component-learningmap/package.json b/packages/web-component-learningmap/package.json
index 7c7518bf..fa7d65ec 100644
--- a/packages/web-component-learningmap/package.json
+++ b/packages/web-component-learningmap/package.json
@@ -21,7 +21,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/openpatch/hyperbook.git",
- "directory": "packages/web-component-learningmap"
+ "directory": "packages/web-component-learningmap-editor"
},
"bugs": {
"url": "https://github.com/openpatch/hyperbook/issues"
@@ -32,18 +32,18 @@
"build": "vite build"
},
"dependencies": {
- "@excalidraw/excalidraw": "0.18.0",
"@r2wc/react-to-web-component": "^2.0.4",
+ "@szhsin/react-menu": "^4.5.0",
"@xyflow/react": "^12.8.6",
"elkjs": "^0.11.0",
- "js-yaml": "^4.1.0",
+ "html-to-image": "1.11.11",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "tslib": "^2.8.1"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.1.2",
- "@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
diff --git a/packages/web-component-learningmap/src/ColorSelector.tsx b/packages/web-component-learningmap/src/ColorSelector.tsx
new file mode 100644
index 00000000..911ee2f4
--- /dev/null
+++ b/packages/web-component-learningmap/src/ColorSelector.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+interface ColorSelectorProps {
+ value: string;
+ onChange: (color: string) => void;
+ label?: string;
+}
+
+export const ColorSelector: React.FC = ({ value, onChange, label }) => {
+ return (
+
+ {label && }
+ onChange(e.target.value)}
+ style={{ width: 32, height: 32, border: "none", background: "none", padding: 0 }}
+ />
+ onChange(e.target.value)}
+ placeholder="#e5e7eb"
+ style={{ width: 100 }}
+ />
+
+ );
+};
diff --git a/packages/web-component-learningmap/src/Drawer.tsx b/packages/web-component-learningmap/src/Drawer.tsx
new file mode 100644
index 00000000..59423151
--- /dev/null
+++ b/packages/web-component-learningmap/src/Drawer.tsx
@@ -0,0 +1,147 @@
+import { Node } from "@xyflow/react";
+import { NodeData } from "./types";
+import { X, Lock, CheckCircle } from "lucide-react";
+import { Video } from "./Video";
+import StarCircle from "./icons/StarCircle";
+import { getTranslations } from "./translations";
+
+interface DrawerProps {
+ open: boolean;
+ onClose: () => void;
+ onUpdate: (node: Node) => void;
+ node: Node;
+ nodes: Node[];
+ onNodeClick: (_: any, node: Node, focus: boolean) => void;
+ language?: string;
+}
+
+function getUnlockConditions(node: Node, nodes: Node[]): Node[] {
+ const unmetNeeds: Node[] = [];
+ if (node.data?.unlock?.after) {
+ node.data.unlock.after.forEach((depId: string) => {
+ const depNode = nodes.find(n => n.id === depId);
+ if (depNode && depNode.data?.state !== 'completed' && depNode.data?.state !== 'mastered') {
+ unmetNeeds.push(depNode);
+ }
+ });
+ }
+ return unmetNeeds;
+}
+
+function getCompletionNeeds(node: Node, nodes: Node[]): Node[] {
+ const unmetNeeds: Node[] = [];
+ if (node.data?.completion?.needs) {
+ node.data.completion.needs.forEach((needId: string) => {
+ const needNode = nodes.find(n => n.id === needId);
+ if (needNode && needNode.data?.state !== 'completed' && needNode.data?.state !== 'mastered') {
+ unmetNeeds.push(needNode);
+ }
+ });
+ }
+ return unmetNeeds;
+}
+
+export function Drawer({ open, onClose, onUpdate, node, nodes, onNodeClick, language = "en" }: DrawerProps) {
+ const t = getTranslations(language);
+
+ if (!open) return null;
+
+ const locked = node.data?.state === 'locked' || false;
+ const unlocked = node.data?.state === 'unlocked' || false;
+ const completed = node.data?.state === 'completed' || false;
+ const started = node.data?.state === 'started' || false;
+ const mastered = node.data?.state === 'mastered' || false;
+
+ const unlockConditions = getUnlockConditions(node, nodes);
+ const completionNeeds = getCompletionNeeds(node, nodes);
+
+ const handleStateChange = (newState: 'locked' | 'unlocked' | 'started' | 'completed') => () => {
+ if (node.type === "topic" || locked) return;
+
+ onUpdate({
+ ...node,
+ data: {
+ ...node.data,
+ state: newState
+ }
+ });
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/packages/web-component-learningmap/src/EdgeDrawer.tsx b/packages/web-component-learningmap/src/EdgeDrawer.tsx
new file mode 100644
index 00000000..00a5a75f
--- /dev/null
+++ b/packages/web-component-learningmap/src/EdgeDrawer.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { X, Trash2, Save } from "lucide-react";
+import { Edge } from "@xyflow/react";
+import { EditorDrawerEdgeContent } from "./EditorDrawerEdgeContent";
+import { getTranslations } from "./translations";
+
+interface EdgeDrawerProps {
+ edge: Edge | null;
+ isOpen: boolean;
+ onClose: () => void;
+ onUpdate: (edge: Edge) => void;
+ onDelete: () => void;
+ language?: string;
+}
+
+export const EdgeDrawer: React.FC = ({
+ edge: selectedEdge,
+ isOpen: edgeDrawerOpen,
+ onClose: closeDrawer,
+ onUpdate: updateEdge,
+ onDelete: deleteEdge,
+ language = "en",
+}) => {
+ const t = getTranslations(language);
+
+ if (!selectedEdge || !edgeDrawerOpen) return null;
+ return (
+
+
+
+
+
{t.editEdge}
+
+
+
{
+ let updated = { ...selectedEdge };
+ if (field === "color") {
+ updated = {
+ ...updated,
+ style: { ...updated.style, stroke: value },
+ };
+ } else if (field === "animated") {
+ updated = { ...updated, animated: value };
+ } else if (field === "type") {
+ updated = { ...updated, type: value };
+ }
+ updateEdge(updated);
+ }}
+ language={language}
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web-component-learningmap/src/EditorDrawer.tsx b/packages/web-component-learningmap/src/EditorDrawer.tsx
new file mode 100644
index 00000000..fcf0df5f
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawer.tsx
@@ -0,0 +1,249 @@
+import React, { useState, useEffect } from "react";
+import { X, Trash2, Save } from "lucide-react";
+import { Node, useReactFlow } from "@xyflow/react";
+import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent";
+import { EditorDrawerTopicContent } from "./EditorDrawerTopicContent";
+import { EditorDrawerImageContent } from "./EditorDrawerImageContent";
+import { EditorDrawerTextContent } from "./EditorDrawerTextContent";
+import { NodeData } from "./types";
+import { getTranslations } from "./translations";
+
+interface EditorDrawerProps {
+ node: Node | null;
+ isOpen: boolean;
+ onClose: () => void;
+ onUpdate: (node: Node) => void;
+ onDelete: () => void;
+ language?: string;
+}
+
+export const EditorDrawer: React.FC = ({
+ node,
+ isOpen,
+ onClose,
+ onUpdate,
+ onDelete,
+ language = "en",
+}) => {
+ const t = getTranslations(language);
+ const [localNode, setLocalNode] = useState | null>(node);
+ const { getNodes } = useReactFlow();
+ const allNodes = getNodes();
+
+ useEffect(() => {
+ setLocalNode(node);
+ }, [node]);
+
+ if (!isOpen || !node || !localNode) return null;
+
+ // Filter out the current node from selectable options
+ const nodeOptions = allNodes.filter(n => n.id !== node.id && n.type === "task" || n.type === "topic");
+
+ // Helper for dropdowns
+ const renderNodeSelect = (value: string, onChange: (id: string) => void) => (
+
+ );
+
+ // Completion Needs
+ const handleCompletionNeedsChange = (idx: number, id: string) => {
+ if (!localNode) return;
+ const needs = [...(localNode.data.completion?.needs || [])];
+ needs[idx] = { id };
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), needs });
+ };
+ const addCompletionNeed = () => {
+ if (!localNode) return;
+ const needs = [...(localNode.data.completion?.needs || []), { id: "" }];
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), needs });
+ };
+ const removeCompletionNeed = (idx: number) => {
+ if (!localNode) return;
+ const needs = (localNode.data.completion?.needs || []).filter((_: any, i: number) => i !== idx);
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), needs });
+ };
+
+ // Completion Optional
+ const handleCompletionOptionalChange = (idx: number, id: string) => {
+ if (!localNode) return;
+ const optional = [...(localNode.data.completion?.optional || [])];
+ optional[idx] = { id };
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), optional });
+ };
+ const addCompletionOptional = () => {
+ if (!localNode) return;
+ const optional = [...(localNode.data.completion?.optional || []), { id: "" }];
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), optional });
+ };
+ const removeCompletionOptional = (idx: number) => {
+ if (!localNode) return;
+ const optional = (localNode.data.completion?.optional || []).filter((_: any, i: number) => i !== idx);
+ handleFieldChange("completion", { ...(localNode.data.completion || {}), optional });
+ };
+
+ // Unlock After
+ const handleUnlockAfterChange = (idx: number, id: string) => {
+ if (!localNode) return;
+ const after = [...(localNode.data.unlock?.after || [])];
+ after[idx] = id;
+ handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after });
+ };
+ const addUnlockAfter = () => {
+ if (!localNode) return;
+ const after = [...(localNode.data.unlock?.after || []), ""];
+ handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after });
+ };
+ const removeUnlockAfter = (idx: number) => {
+ if (!localNode) return;
+ const after = (localNode.data.unlock?.after || []).filter((_: any, i: number) => i !== idx);
+ handleFieldChange("unlock", { ...(localNode.data.unlock || {}), after });
+ };
+
+ const handleSave = () => {
+ if (!localNode) return;
+ onUpdate(localNode);
+ onClose();
+ };
+
+ const handleFieldChange = (field: string, value: any) => {
+ setLocalNode((prev: Node | null) => ({
+ ...prev!,
+ data: { ...prev!.data, [field]: value },
+ }));
+ };
+
+ const handleResourceChange = (index: number, field: string, value: string) => {
+ if (!localNode) return;
+ const resources = [...(localNode.data.resources || [])];
+ resources[index] = { ...resources[index], [field]: value };
+ handleFieldChange("resources", resources);
+ };
+
+ const addResource = () => {
+ if (!localNode) return;
+ const resources = [...(localNode.data.resources || []), { label: "", url: "" }];
+ handleFieldChange("resources", resources);
+ };
+
+ const removeResource = (index: number) => {
+ if (!localNode) return;
+ const resources = (localNode.data.resources || []).filter((_: any, i: number) => i !== index);
+ handleFieldChange("resources", resources);
+ };
+
+ let content;
+ if (localNode.type === "task") {
+ content = (
+ <>
+
+
{t.editTask}
+
+
+
+ >
+ );
+ } else if (localNode.type === "topic") {
+ content = (
+ <>
+
+
{t.editTopic}
+
+
+
+ >
+ );
+ } else if (localNode.type === "image") {
+ content = (
+ <>
+
+
{t.editImage}
+
+
+
+ >
+ );
+ } else if (localNode.type === "text") {
+ content = (
+ <>
+
+
{t.editText}
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ {content}
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx
new file mode 100644
index 00000000..30423100
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawerEdgeContent.tsx
@@ -0,0 +1,52 @@
+import { ColorSelector } from "./ColorSelector";
+import { Edge } from "@xyflow/react";
+import { getTranslations } from "./translations";
+
+interface Props {
+ localEdge: Edge;
+ handleFieldChange: (field: string, value: any) => void;
+ language?: string;
+}
+
+export function EditorDrawerEdgeContent({
+ localEdge,
+ handleFieldChange,
+ language = "en",
+}: Props) {
+ const t = getTranslations(language);
+
+ return (
+
+
+ handleFieldChange("color", color)}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx
new file mode 100644
index 00000000..7717b333
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawerImageContent.tsx
@@ -0,0 +1,47 @@
+import { Node } from "@xyflow/react";
+import { ImageNodeData } from "./types";
+import { getTranslations } from "./translations";
+
+interface Props {
+ localNode: Node;
+ handleFieldChange: (field: string, value: any) => void;
+ language?: string;
+}
+
+export function EditorDrawerImageContent({ localNode, handleFieldChange, language = "en" }: Props) {
+ const t = getTranslations(language);
+
+ // Convert file to base64 and update node data
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === "string") {
+ handleFieldChange("data", reader.result);
+ }
+ };
+ reader.readAsDataURL(file);
+ };
+
+ return (
+
+
+
+
+
+ {localNode.data.data && (
+
+
+
+

+
+
+ )}
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx
new file mode 100644
index 00000000..66e48083
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawerTaskContent.tsx
@@ -0,0 +1,220 @@
+import { Node } from "@xyflow/react";
+import { Plus, Trash2 } from "lucide-react";
+import { NodeData } from "./types";
+import { getTranslations } from "./translations";
+
+interface Props {
+ localNode: Node;
+ handleFieldChange: (field: string, value: any) => void;
+ handleResourceChange: (index: number, field: string, value: string) => void;
+ addResource: () => void;
+ removeResource: (index: number) => void;
+ handleUnlockAfterChange: (idx: number, id: string) => void;
+ addUnlockAfter: () => void;
+ removeUnlockAfter: (idx: number) => void;
+ renderNodeSelect: (value: string, onChange: (id: string) => void) => React.ReactNode;
+ handleCompletionNeedsChange: (idx: number, id: string) => void;
+ addCompletionNeed: () => void;
+ removeCompletionNeed: (idx: number) => void;
+ handleCompletionOptionalChange: (idx: number, id: string) => void;
+ addCompletionOptional: () => void;
+ removeCompletionOptional: (idx: number) => void;
+ language?: string;
+}
+
+export function EditorDrawerTaskContent({
+ localNode,
+ handleFieldChange,
+ handleResourceChange,
+ addResource,
+ removeResource,
+ handleUnlockAfterChange,
+ addUnlockAfter,
+ removeUnlockAfter,
+ renderNodeSelect,
+ handleCompletionNeedsChange,
+ addCompletionNeed,
+ removeCompletionNeed,
+ handleCompletionOptionalChange,
+ addCompletionOptional,
+ removeCompletionOptional,
+ language = "en",
+}: Props) {
+ const t = getTranslations(language);
+
+ // Color options for the dropdown
+ const colorOptions = [
+ { value: "blue", label: t.blue, className: "react-flow__node-topic blue" },
+ { value: "yellow", label: t.yellow, className: "react-flow__node-topic yellow" },
+ { value: "lila", label: t.lila, className: "react-flow__node-topic lila" },
+ { value: "pink", label: t.pink, className: "react-flow__node-topic pink" },
+ { value: "teal", label: t.teal, className: "react-flow__node-topic teal" },
+ { value: "red", label: t.red, className: "react-flow__node-topic red" },
+ { value: "black", label: t.black, className: "react-flow__node-topic black" },
+ { value: "white", label: t.white, className: "react-flow__node-topic white" },
+ ];
+
+ // Determine default color based on node type
+ let defaultColor = "blue";
+ if (localNode.type === "topic") defaultColor = "yellow";
+ const selectedColor = localNode.data?.color || defaultColor;
+ return (
+
+
+
+
+ {colorOptions.map(opt => (
+
+ ))}
+
+
+
+
+ handleFieldChange("label", e.target.value)}
+ placeholder={t.placeholderNodeLabel}
+ />
+
+
+
+ handleFieldChange("summary", e.target.value)}
+ placeholder={t.placeholderShortSummary}
+ />
+
+
+
+
+
+
+ handleFieldChange("duration", e.target.value)}
+ placeholder="e.g., 30 min"
+ />
+
+
+
+ handleFieldChange("video", e.target.value)}
+ placeholder={t.placeholderVideoURL}
+ />
+
+
+
+
+ handleFieldChange("unlock", { ...(localNode.data.unlock || {}), password: e.target.value })}
+ placeholder={t.placeholderOptionalPassword}
+ />
+
+
+
+ handleFieldChange("unlock", { ...(localNode.data.unlock || {}), date: e.target.value })}
+ />
+
+
+
+ {(localNode.data.unlock?.after || []).map((id: string, idx: number) => (
+
+ {renderNodeSelect(id, newId => handleUnlockAfterChange(idx, newId))}
+
+
+ ))}
+
+
+ {localNode.type === "topic" &&
+
+ {(localNode.data.completion?.needs || []).map((need: string, idx: number) => (
+
+ {renderNodeSelect(need, newId => handleCompletionNeedsChange(idx, newId))}
+
+
+ ))}
+
+
}
+ {localNode.type === "topic" &&
+
+ {(localNode.data.completion?.optional || []).map((opt: string, idx: number) => (
+
+ {renderNodeSelect(opt, newId => handleCompletionOptionalChange(idx, newId))}
+
+
+ ))}
+
+
}
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx
new file mode 100644
index 00000000..bf1e8dbd
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawerTextContent.tsx
@@ -0,0 +1,48 @@
+import { Node } from "@xyflow/react";
+import { TextNodeData } from "./types";
+import { ColorSelector } from "./ColorSelector";
+import { RotationInput } from "./RotationInput";
+import { getTranslations } from "./translations";
+
+interface Props {
+ localNode: Node;
+ handleFieldChange: (field: string, value: any) => void;
+ language?: string;
+}
+
+export function EditorDrawerTextContent({ localNode, handleFieldChange, language = "en" }: Props) {
+ const t = getTranslations(language);
+
+ return (
+
+
+
+ handleFieldChange("text", e.target.value)}
+ placeholder={t.placeholderBackgroundText}
+ />
+
+
+
+ handleFieldChange("fontSize", Number(e.target.value))}
+ />
+
+
+ handleFieldChange("color", color)}
+ />
+
+
handleFieldChange("rotation", v)}
+ />
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx b/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx
new file mode 100644
index 00000000..ee7ed8e3
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorDrawerTopicContent.tsx
@@ -0,0 +1,6 @@
+import { EditorDrawerTaskContent } from "./EditorDrawerTaskContent";
+
+// For now, topic content is the same as task content. You can customize later if needed.
+export function EditorDrawerTopicContent(props: any) {
+ return ;
+}
diff --git a/packages/web-component-learningmap/src/EditorToolbar.tsx b/packages/web-component-learningmap/src/EditorToolbar.tsx
new file mode 100644
index 00000000..5e2e8032
--- /dev/null
+++ b/packages/web-component-learningmap/src/EditorToolbar.tsx
@@ -0,0 +1,98 @@
+import React from "react";
+import { Menu, MenuButton, MenuItem, SubMenu } from "@szhsin/react-menu";
+import "@szhsin/react-menu/dist/index.css";
+import '@szhsin/react-menu/dist/transitions/zoom.css';
+import { Save, Plus, Bug, Settings, Eye, Menu as MenuI, FolderOpen, Download, ImageDown } from "lucide-react";
+import { getTranslations } from "./translations";
+
+interface EditorToolbarProps {
+ saved: boolean;
+ debugMode: boolean;
+ previewMode: boolean;
+ showCompletionNeeds: boolean;
+ showCompletionOptional: boolean;
+ showUnlockAfter: boolean;
+ onToggleDebugMode: () => void;
+ onTogglePreviewMode: () => void;
+ onSetShowCompletionNeeds: (checked: boolean) => void;
+ onSetShowCompletionOptional: (checked: boolean) => void;
+ onSetShowUnlockAfter: (checked: boolean) => void;
+ onAddNewNode: (type: "task" | "topic" | "image" | "text") => void;
+ onOpenSettingsDrawer: () => void;
+ onSave: () => void;
+ onDownlad: () => void;
+ onOpen: () => void;
+ onExportSVG: () => void;
+ language?: string;
+}
+
+export const EditorToolbar: React.FC = ({
+ saved,
+ debugMode,
+ previewMode,
+ showCompletionNeeds,
+ showCompletionOptional,
+ showUnlockAfter,
+ onTogglePreviewMode,
+ onToggleDebugMode,
+ onSetShowCompletionNeeds,
+ onSetShowCompletionOptional,
+ onSetShowUnlockAfter,
+ onAddNewNode,
+ onOpenSettingsDrawer,
+ onSave,
+ onDownlad,
+ onOpen,
+ onExportSVG,
+ language = "en",
+}) => {
+ const t = getTranslations(language);
+
+ return (
+
+
+
+
+
+
+
+
+
+);};
diff --git a/packages/web-component-learningmap/src/FloatingEdge.tsx b/packages/web-component-learningmap/src/FloatingEdge.tsx
new file mode 100644
index 00000000..29b7528b
--- /dev/null
+++ b/packages/web-component-learningmap/src/FloatingEdge.tsx
@@ -0,0 +1,39 @@
+import { Edge, getBezierPath, useInternalNode } from '@xyflow/react';
+
+import { getEdgeParams } from './helper';
+
+const FloatingEdge = ({ id, source, target, markerEnd, style }: Edge) => {
+ const sourceNode = useInternalNode(source);
+ const targetNode = useInternalNode(target);
+
+ if (!sourceNode || !targetNode) {
+ return null;
+ }
+
+ const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
+ sourceNode,
+ targetNode,
+ );
+
+ const [edgePath] = getBezierPath({
+ sourceX: sx,
+ sourceY: sy,
+ sourcePosition: sourcePos,
+ targetPosition: targetPos,
+ targetX: tx,
+ targetY: ty,
+ });
+
+ return (
+
+ );
+}
+
+export default FloatingEdge;
+
diff --git a/packages/web-component-learningmap/src/HyperbookLearningmap.tsx b/packages/web-component-learningmap/src/HyperbookLearningmap.tsx
index 5454e3d9..b9708471 100644
--- a/packages/web-component-learningmap/src/HyperbookLearningmap.tsx
+++ b/packages/web-component-learningmap/src/HyperbookLearningmap.tsx
@@ -1,1146 +1,32 @@
-import { useState, useCallback, useEffect } from "react";
-import { getAutoLayoutedNodesElk } from "./autoLayoutElk";
-import * as yaml from "js-yaml";
import {
- ReactFlow,
- Controls,
- useNodesState,
- useEdgesState,
ReactFlowProvider,
- Handle,
- Position,
- ColorMode,
- useReactFlow,
- Node,
- useOnViewportChange,
} from "@xyflow/react";
-import {
- CheckCircle2,
- Circle,
- Lock,
- Clock,
- Compass,
- Star
-} from "lucide-react";
-
-interface UnlockCondition {
- after?: string[]; // Node IDs that must be completed
- date?: string; // ISO date string for unlock
- password?: string; // Password required to unlock
-}
-
-interface CompletionNeed {
- id: string;
- source?: string;
- target?: string;
-}
-
-interface Completion {
- needs?: CompletionNeed[];
- optional?: CompletionNeed[];
-}
-
-interface TopicNodeData {
- label: string;
- description?: string;
- duration?: string;
- position?: { x: number; y: number };
- unlock?: UnlockCondition;
- completion?: Completion;
- video?: string;
- resources?: { label: string; url: string }[];
- summary?: string;
- [key: string]: any;
-}
-
-interface TaskNodeData {
- label: string;
- description?: string;
- duration?: string;
- position?: { x: number; y: number };
- unlock?: UnlockCondition;
- video?: string;
- resources?: { label: string; url: string }[];
- summary?: string;
- [key: string]: any;
-}
-
-type LearningNodeData = TaskNodeData | TopicNodeData;
-
-// ============================================================================
-// VIDEO UTILS
-// ============================================================================
-
-function isYoutubeUrl(url: string) {
- return (
- typeof url === "string" &&
- (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/"))
- );
-}
-
-function getYoutubeEmbedUrl(url: string) {
- if (url.includes("youtube.com/watch?v=")) {
- const videoId = url.split("v=")[1].split("&")[0];
- return `https://www.youtube-nocookie.com/embed/${videoId}`;
- }
- if (url.includes("youtu.be/")) {
- const videoId = url.split("youtu.be/")[1].split("?")[0];
- return `https://www.youtube-nocookie.com/embed/${videoId}`;
- }
- return url;
-}
-
-function getVideoMimeType(url: string) {
- if (url.endsWith(".webm")) return "video/webm";
- if (url.endsWith(".mp4")) return "video/mp4";
- return "video/mp4";
-}
-
-// ============================================================================
-// CONSTANTS & TYPES
-// ============================================================================
-
-const TEXTS = {
- en: {
- trackJourney: "Track your learning journey",
- progress: "Progress",
- completed: "Completed",
- available: "Available",
- locked: "Locked",
- topicsCompleted: "{completed} of {total} completed",
- markAsComplete: "Mark as Complete",
- markAsIncomplete: "Mark as Incomplete",
- unlock: "Unlock",
- unlocked: "Unlocked",
- lockedBtn: "Locked",
- resources: "Resources",
- optional: "Optional",
- passwordPlaceholder: "Password",
- incorrectPassword: "Incorrect password",
- startTask: "Start the Task",
- inProgressLegend: "In Progress",
- unlockPasswordHint: "Enter password to unlock this node.",
- unlockDateHint: "This node unlocks after {date}.",
- unlockAfterHint: "Complete these first:",
- unlockClose: "Close",
- unlockButton: "Unlock",
- task: "Task"
- },
- // ...other languages unchanged...
- de: {
- trackJourney: "Verfolge deinen Lernfortschritt",
- progress: "Fortschritt",
- completed: "Abgeschlossen",
- available: "Verfügbar",
- locked: "Gesperrt",
- topicsCompleted: "{completed} von {total} abgeschlossen",
- markAsComplete: "Als abgeschlossen markieren",
- markAsIncomplete: "Als nicht abgeschlossen markieren",
- unlock: "Entsperren",
- unlocked: "Entsperrt",
- lockedBtn: "Gesperrt",
- resources: "Ressourcen",
- optional: "Optional",
- passwordPlaceholder: "Passwort",
- incorrectPassword: "Falsches Passwort",
- startTask: "Starte die Aufgabe",
- inProgressLegend: "In Bearbeitung",
- unlockPasswordHint: "Passwort eingeben, um dieses Thema zu entsperren.",
- unlockDateHint: "Dieses Thema wird nach dem {date} freigeschaltet.",
- unlockAfterHint: "Folgende zuerst abschließen:",
- unlockClose: "Schließen",
- unlockButton: "Entsperren",
- task: "Aufgabe"
- },
- // ...other languages unchanged...
- es: {
- trackJourney: "Sigue tu trayectoria de aprendizaje",
- progress: "Progreso",
- completed: "Completado",
- available: "Disponible",
- locked: "Bloqueado",
- topicsCompleted: "{completed} de {total} completados",
- markAsComplete: "Marcar como completado",
- markAsIncomplete: "Marcar como incompleto",
- unlock: "Desbloquear",
- unlocked: "Desbloqueado",
- lockedBtn: "Bloqueado",
- resources: "Recursos",
- optional: "Opcional",
- passwordPlaceholder: "Ingresa la contraseña",
- incorrectPassword: "Contraseña incorrecta",
- startTask: "Iniciar la tarea",
- inProgressLegend: "En progreso",
- unlockPasswordHint: "Ingresa la contraseña para desbloquear este nodo.",
- unlockDateHint: "Este nodo se desbloquea después de {date}.",
- unlockAfterHint: "Completa estos primero:",
- unlockClose: "Cerrar",
- unlockButton: "Desbloquear",
- task: "Tarea"
- },
- fr: {
- trackJourney: "Suivez votre parcours d'apprentissage",
- progress: "Progrès",
- completed: "Terminé",
- available: "Disponible",
- locked: "Verrouillé",
- topicsCompleted: "{completed} sur {total} terminés",
- markAsComplete: "Marquer comme terminé",
- markAsIncomplete: "Marquer comme incomplet",
- unlock: "Déverrouiller",
- unlocked: "Déverrouillé",
- lockedBtn: "Verrouillé",
- resources: "Ressources",
- optional: "Optionnel",
- passwordPlaceholder: "Entrez le mot de passe",
- incorrectPassword: "Mot de passe incorrect",
- startTask: "Commencer la tâche",
- inProgressLegend: "En cours",
- unlockPasswordHint: "Entrez le mot de passe pour déverrouiller ce nœud.",
- unlockDateHint: "Ce nœud se déverrouille après le {date}.",
- unlockAfterHint: "Terminez ceux-ci d'abord :",
- unlockClose: "Fermer",
- unlockButton: "Déverrouiller",
- task: "Tâche"
- },
-};
-
-// ============================================================================
-// HELPER FUNCTIONS
-// ============================================================================
-
-const parseRoadmapData = (roadmapData: string) => {
- if (typeof roadmapData !== "string") {
- return roadmapData;
- }
-
- try {
- return JSON.parse(roadmapData);
- } catch {
- try {
- return yaml.load(roadmapData);
- } catch (err) {
- console.error("Failed to parse roadmap data:", err);
- return {};
- }
- }
-};
-
-const generateEdgesFromCompletionNeeds = (nodes: Node[], edgeConfig) => {
- const edges = [];
- nodes.forEach((node) => {
- const needs = node.data?.completion?.needs || [];
- needs.forEach((need: CompletionNeed) => {
- if (need.id) {
- edges.push({
- id: `${need.id}->${node.id}`,
- source: need.id,
- target: node.id,
- sourceHandle: need.source || "bottom",
- targetHandle: need.target || "top",
- });
- }
- });
- const optional = node.data?.completion?.optional || [];
- optional.forEach((opt: CompletionNeed) => {
- if (opt.id) {
- edges.push({
- id: `${opt.id}->${node.id}-optional`,
- source: opt.id,
- target: node.id,
- sourceHandle: opt.source || "bottom",
- targetHandle: opt.target || "top",
- style: { strokeDasharray: "5,5", strokeWidth: edgeConfig.width ?? 2, stroke: edgeConfig.color ?? "#94a3b8" },
- });
- }
- });
- });
- return edges;
-};
-
-const areUnlockAfterCompleted = (node: Node, stateLookup: Record) => {
- const unlockAfter = node.data?.unlock?.after || [];
- return unlockAfter.every((id: string) => stateLookup[id]?.state === "completed");
-};
-
-const isUnlockDatePassed = (node: Node) => {
- const unlockDate = node.data?.unlock?.date;
- if (!unlockDate) return true;
- return new Date() >= new Date(unlockDate);
-};
-
-const isUnlockedByPassword = (node: Node, savedPassword: boolean) => {
- const password = node.data?.unlock?.password;
- if (!password) return true;
- return savedPassword;
-};
-
-const getLockedStyle = (isLocked: boolean) => {
- return isLocked
- ? {
- background: "#f3f4f6",
- color: "#a1a1aa",
- borderColor: "#d1d5db",
- boxShadow: "none",
- }
- : {};
-};
-
-function areCompletionNeedsMet(node: Node, nodesArr: Node[]) {
- const needs = node.data?.completion?.needs || [];
- if (needs.length === 0) return true;
- return needs.every((need: CompletionNeed) => {
- const neededNode = nodesArr.find(n => n.id === need.id);
- return neededNode?.data?.state === "completed" || neededNode?.data?.state === "fully-completed";
- });
-}
-
-function getTopicNodeStatus(node: Node, stateLookup: Record) {
- if (node.type !== "topic") return node.data.state;
-
- const needs = node.data?.completion?.needs || [];
- const optional = node.data?.completion?.optional || [];
-
- const needsStates = needs.map((n: { id: string }) => stateLookup[n.id]?.state);
- const optionalStates = optional.map((n: { id: string }) => stateLookup[n.id]?.state);
-
- const anyStartedOrCompleted = [...needsStates, ...optionalStates].some(
- s => s === "started" || s === "completed" || s === "fully-completed"
- );
- const allNeedsCompleted = needs.length > 0 && needsStates.every(s => s === "completed" || s === "fully-completed");
- const allOptionalCompleted = optional.length === 0 || optionalStates.every(s => s === "completed" || s === "fully-completed");
- const allNeedsAndOptionalCompleted = allNeedsCompleted && allOptionalCompleted;
-
- if (allNeedsAndOptionalCompleted) return "fully-completed";
- if (allNeedsCompleted) return "completed";
- if (anyStartedOrCompleted) return "started";
- return undefined;
-}
-
-function getAllOptionalNodeIds(nodes: Node[]) {
- const ids = [];
- nodes.forEach(n => {
- if (n.type === "topic" && Array.isArray(n.data?.completion?.optional)) {
- n.data.completion.optional.forEach((opt: { id: string }) => {
- if (opt.id) ids.push(opt.id);
- });
- }
- });
- return ids;
-}
-
-// ============================================================================
-// UNLOCK ICON & POPUP
-// ============================================================================
-
-function getLocaleFromLanguage(language: string): string {
- // Map your language code to a locale string
- switch (language) {
- case "de": return "de-DE";
- case "es": return "es-ES";
- case "fr": return "fr-FR";
- case "en":
- default: return "en-US";
- }
-}
-
-function getUnlockInfo(unlock: UnlockCondition, t, language = "en") {
- if (!unlock) return null;
- if (unlock.password) {
- return { icon: , hint: t.unlockPasswordHint, type: "password" };
- }
- if (unlock.date) {
- // Format date according to locale
- const locale = getLocaleFromLanguage(language);
- const dateObj = new Date(unlock.date);
- const formattedDate = isNaN(dateObj.getTime())
- ? unlock.date
- : dateObj.toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" });
- return { icon: , hint: t.unlockDateHint.replace("{date}", formattedDate), type: "date" };
- }
- if (unlock.after && unlock.after.length > 0) {
- return { icon: , hint: t.unlockAfterHint, type: "after" };
- }
- return null;
-}
-
-// ============================================================================
-// NODE COMPONENTS
-// ============================================================================
-
-const LearningNode = ({ data, type, language }) => {
- const t = TEXTS[language] || TEXTS.en;
-
- const isCompleted = data.state === "completed";
- const isFullyCompleted = data.state === "fully-completed";
- const isLocked = data.locked;
- const isStarted = data.state === "started";
- const isOptional = data.isOptional;
-
- const unlockInfo = getUnlockInfo(data.unlock, t, language);
-
- const lockedStyle = getLockedStyle(isLocked);
- const startedStyle = isStarted ? { borderColor: "#f59e42" } : {};
-
- return (
-
-
- {type === "task" && (
-
- {t.task}
-
- )}
-
- {unlockInfo ? (
- unlockInfo.icon
- ) : (isCompleted || isFullyCompleted) ? (
-
- ) : isStarted ? (
-
- ) : (
-
- )}
-
- {data.label}
-
-
-
-
- {isFullyCompleted && (
-
- )}
-
- {data.summary && (
-
- {data.summary}
-
- )}
-
- {["Bottom", "Top", "Left", "Right"].map((pos) => (
-
- ))}
-
- {["Bottom", "Top", "Left", "Right"].map((pos) => (
-
- ))}
-
- );
-};
-
-const BackgroundNode = ({ data }) => {
- if (!data.image?.src) return null;
- return
;
-};
-
-const DebugNode = ({ data }) => {
- const { screenToFlowPosition } = useReactFlow();
- const [mousePos, setMousePos] = useState(null);
-
- if (!data?.bounds) return null;
-
- const handleMouseMove = (e: any) => {
- const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
- setMousePos(flowPos);
- };
-
- useEffect(() => {
- window.addEventListener("mousemove", handleMouseMove);
- return () => window.removeEventListener("mousemove", handleMouseMove);
- }, []);
-
- return (
-
-
Debug Node
-
Position: x={data.bounds.x}, y={data.bounds.y}
-
- Mouse Position:
- {mousePos
- ? `x=${mousePos.x.toFixed(2)}, y=${mousePos.y.toFixed(2)}`
- : 'Move mouse over node'}
-
-
Size: width={data.bounds.width}, height={data.bounds.height}
-
- );
-};
-
-// ============================================================================
-// DRAWER COMPONENTS
-// ============================================================================
-
-const NodeDrawer = ({ node, isOpen, onClose, onPasswordUnlock, onToggleComplete, language = "en", setNode }) => {
- const t = TEXTS[language] || TEXTS.en;
- if (!isOpen || !node) return null;
-
- const { getNodes } = useReactFlow();
- const nodesArr = getNodes();
-
- const { state, locked: isLocked, completion, unlock, label, duration, description, video, resources } = node.data;
-
- // Check completion needs
- const unmetNeeds = (completion?.needs || [])
- .map((need) => {
- const n = nodesArr.find((n) => n.id === need.id);
- const completed = n?.data?.state === "completed" || n?.data?.state === "fully-completed";
- return !completed ? (n?.data?.label || need.id) : null;
- })
- .filter(Boolean);
-
- const needsMet = unmetNeeds.length === 0;
-
- const [passwordInput, setPasswordInput] = useState("");
- const [passwordError, setPasswordError] = useState(false);
-
- // Password unlock handler
- const handlePasswordUnlock = (e: any) => {
- e.preventDefault();
- if (passwordInput === unlock.password) {
- onPasswordUnlock();
- setPasswordError(false);
- } else {
- setPasswordError(true);
- }
- };
-
- let buttonText = t.startTask;
- if (state === "started") buttonText = t.markAsComplete;
- if (state === "completed" || state === "fully-completed") buttonText = t.completed;
-
- const buttonStyle = state === "started" ? { backgroundColor: "#f59e42", color: "#fff" } : {};
- const unlockInfo = getUnlockInfo(unlock, t, language);
-
- let content = null
- if (!isLocked) {
- content = (
- <>
- {description && (
-
- {description.split("\n").map((text: string, idx: number) => (
-
{text}
- ))}
-
- )}
-
- {video && (
-
- {isYoutubeUrl(video) ? (
-
- ) : (
-
- )}
-
- )}
-
- {Array.isArray(resources) && resources.length > 0 && (
-
- )}
-
- {!needsMet && (
-
-
{t.unlockAfterHint}
-
- {unmetNeeds.map((label, idx) => (
- - {label}
- ))}
-
-
- )}
- >
- )
- } else if (unlockInfo.type === "password") {
- content = (
-
-
- {unlockInfo.hint}
-
-
setPasswordInput(e.target.value)}
- style={{ marginRight: "0.5em" }}
- />
-
- {passwordError && (
-
{t.incorrectPassword}
- )}
-
- )
- } else if (unlockInfo.type === "date") {
-
- content = (
-
-
- {unlockInfo.hint}
-
-
- )
- } else if (unlockInfo.type === "after") {
- const needsList = unlock?.after
- ? unlock.after.map((id) => {
- const n = nodesArr.find((n) => n.id === id);
- const completed = n?.data?.state === "completed" || n?.data?.state === "fully-completed";
- return { label: n?.data?.label || id, completed };
- })
- : [];
-
- content = needsList.length > 0 && (
-
-
- {unlockInfo.hint}
-
-
- {needsList.map((need, idx) => (
- -
- {need.label}
-
- ))}
-
-
- )
-
- }
-
- return (
- <>
-
-
-
-
{label}
- {duration &&
⏱️ {duration}
}
-
-
-
- {content}
-
-
-
-
-
-
- >
- );
-};
-
-// ============================================================================
-// MAIN COMPONENT
-// ============================================================================
-
-function HyperbookLearningmapInner({ roadmapData, nodeState, language = "en", x = 0, y = 0, zoom = 1 }) {
- const t = TEXTS[language] || TEXTS.en;
- const { getNodesBounds, setViewport } = useReactFlow();
-
- const [nodes, setNodes] = useNodesState([]);
- const [edges, setEdges] = useEdgesState([]);
- const [colorMode, setColorMode] = useState("light");
- const [selectedNode, setSelectedNode] = useState(null);
- const [drawerOpen, setDrawerOpen] = useState(false);
- const [showDebugNode, setShowDebugNode] = useState(false);
- const [unlockedPasswords, setUnlockedPasswords] = useState({});
-
- const parsedRoadmap = parseRoadmapData(roadmapData);
- const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : [];
- const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : [];
- const completedMap = nodeState || {};
-
-
- // Edge configuration
- const edgeConfig = parsedRoadmap?.edges || {};
- const defaultEdgeOptions = {
- animated: edgeConfig.animated ?? false,
- style: {
- stroke: edgeConfig.color ?? "#94a3b8",
- strokeWidth: edgeConfig.width ?? 2,
- },
- type: edgeConfig.type ?? "default",
- selectable: false,
- };
-
- // Create background node if configured
- const createBackgroundNode = () => {
- if (!parsedRoadmap?.background?.image) return null;
- const img = parsedRoadmap.background.image;
- return {
- id: "background-image",
- type: "background",
- position: { x: img.x ?? 0, y: img.y ?? 0 },
- data: { image: { src: img.src } },
- draggable: false,
- selectable: false,
- focusable: false,
- zIndex: -1,
- };
- };
-
- // Create debug node
- const createDebugNode = (currentNodes: Node[]) => {
- if (!showDebugNode || !currentNodes.length) return null;
- const filteredNodes = currentNodes.filter((n) => n.type !== "debug");
- const bounds = getNodesBounds(filteredNodes);
- return {
- id: "debug-node",
- type: "debug",
- position: { x: bounds.x, y: bounds.y },
- data: { bounds },
- style: {
- outline: "2px solid red",
- background: "rgba(255,255,255,0.8)",
- fontSize: "32px",
- width: bounds.width,
- height: bounds.height,
- },
- draggable: false,
- selectable: false,
- focusable: false,
- zIndex: 999,
- };
- };
-
- const updateNodes = (nodes: Node[]): Node[] => {
- let stateLookup: Record = Object.fromEntries(
- nodes.map((n) => [n.id, { state: n.data.state }])
- );
-
- let recalculatedNodes = nodes.map((node) => {
- let locked = false;
- if (node.data?.unlock) {
- if (node.data.unlock.after && node.data.unlock.after.length > 0) {
- if (!areUnlockAfterCompleted(node, stateLookup)) locked = true;
- }
- if (node.data.unlock.date) {
- if (!isUnlockDatePassed(node)) locked = true;
- }
- if (node.data.unlock.password) {
- if (!isUnlockedByPassword(node, node.data?.password)) locked = true;
- }
- }
-
- return {
- ...node,
- data: {
- ...node.data,
- locked,
- state: locked ? undefined : node.data.state,
- password: locked ? false : node.data.password
- },
- };
- });
-
- stateLookup = Object.fromEntries(
- recalculatedNodes.map((n) => [n.id, { state: n.data.state }])
- );
-
- recalculatedNodes = recalculatedNodes.map((node) => {
- if (node.type === "topic") {
- const state = getTopicNodeStatus(node, stateLookup);
- return {
- ...node,
- data: {
- ...node.data,
- state,
- },
- };
- }
- return node;
- });
-
- const minimalState = {};
- recalculatedNodes.forEach((n) => {
- if (n.data.state) {
- minimalState[n.id] = { state: n.data.state, password: n.data.password };
- }
- });
-
- const root = document.querySelector("hyperbook-learningmap");
- if (root) {
- root.dispatchEvent(new CustomEvent("change", { detail: minimalState }));
- }
-
-
- return recalculatedNodes;
-
- }
-
- useOnViewportChange({
- onChange: (viewport) => {
- const root = document.querySelector("hyperbook-learningmap");
- if (root) {
- root.dispatchEvent(new CustomEvent("viewport-change", {
- detail: {
- x: viewport.x,
- y: viewport.y,
- zoom: viewport.zoom,
- }
- }));
- }
- }
- });
-
- // Initialize layout
- useEffect(() => {
- async function layoutNodes() {
- const rawNodes = ((nodes: Node[]) => {
- const stateLookup = Object.fromEntries(
- nodes.map((n: Node) => [n.id, completedMap[n.id] || {}])
- );
-
- const allOptionalIds = getAllOptionalNodeIds(nodes);
-
- return nodes.map((node: Node) => {
- const nodeStateObj = completedMap[node.id] || {};
- let state = nodeStateObj.state;
- const password = nodeStateObj.password;
-
- // Unlock logic
- let locked = false;
- if (node.data?.unlock) {
- if (node.data.unlock.after && node.data.unlock.after.length > 0) {
- if (!areUnlockAfterCompleted(node, stateLookup)) locked = true;
- }
- if (node.data.unlock.date) {
- if (!isUnlockDatePassed(node)) locked = true;
- }
- if (node.data.unlock.password) {
- if (!isUnlockedByPassword(node, password)) locked = true;
- }
- }
- //
- // Calculate topic node status
- if (node.type === "topic") {
- state = getTopicNodeStatus(node, stateLookup);
- }
-
- return {
- ...node,
- draggable: false,
- data: {
- ...node.data,
- state: locked ? undefined : state,
- password: locked ? false : password,
- locked,
- isOptional: allOptionalIds.includes(node.id),
- },
- };
- });
- })(nodesArr);
- const rawEdges = edgesArr.length > 0
- ? edgesArr
- : generateEdgesFromCompletionNeeds(rawNodes, edgeConfig);
-
- setEdges(rawEdges);
-
- const needsLayout = rawNodes.some((n: Node) => !n.position);
- if (needsLayout) {
- const autoNodes = await getAutoLayoutedNodesElk(rawNodes, rawEdges);
- setNodes(autoNodes);
- } else {
- setNodes(rawNodes);
- }
-
- setViewport({ x, y, zoom });
- }
- layoutNodes();
- // eslint-disable-next-line
- }, [roadmapData, nodeState, x, y, zoom, unlockedPasswords, setViewport]);
-
- // Debug mode keyboard shortcut
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.code === "Space") {
- setShowDebugNode((prev) => !prev);
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, []);
-
- // Event handlers
- const onNodeClick = useCallback((_: any, node: Node) => {
- if (node.type === "debug" || node.type === "background") return;
- setSelectedNode(node);
- setDrawerOpen(true);
- }, []);
-
- const closeDrawer = useCallback(() => {
- setDrawerOpen(false);
- setSelectedNode(null);
- }, []);
-
- const passwordNodeUnlock = useCallback(() => {
- if (!selectedNode) return;
-
- setNodes(nds => {
- const newNds = nds.map(n => {
- if (n.id == selectedNode.id) {
- return {
- ...n,
- data: {
- ...n.data,
- locked: false,
- password: true
- }
- }
- }
-
- return n;
- })
- return updateNodes(newNds);
- })
-
- }, [selectedNode, parsedRoadmap])
-
- const toggleNodeComplete = useCallback(() => {
- if (!selectedNode || selectedNode.data.locked) return;
-
- // Prevent completion if needs are not met
- const nodesArr = nodes;
- if (!areCompletionNeedsMet(selectedNode, nodesArr)) return;
-
- setNodes((nds) => {
- const newNds = nds.map((n) => {
- if (n.id === selectedNode.id) {
- let newState: string;
- if (!n.data.state) {
- newState = "started";
- } else if (n.data.state === "started") {
- newState = "completed";
- } else if (n.data.state === "completed") {
- newState = undefined;
- }
-
- const updatedNode = {
- ...n,
- data: { ...n.data, state: newState },
- };
-
- setSelectedNode(updatedNode);
-
- return updatedNode;
- }
- return n;
- });
-
- return updateNodes(newNds);
- });
- }, [selectedNode, parsedRoadmap]);
-
- // Prepare nodes for display
- const backgroundNode = createBackgroundNode();
- const debugNode = createDebugNode(nodes);
- const displayNodes = [
- ...(backgroundNode ? [backgroundNode] : []),
- ...nodes,
- ...(debugNode ? [debugNode] : []),
- ];
-
- // Calculate progress
- // Progress calculation (ignore optional nodes)
- const nonOptionalNodes = nodes.filter(n => {
- // Exclude nodes that are only referenced as optional in any topic
- const allOptionalIds = getAllOptionalNodeIds(nodes);
- return !allOptionalIds.includes(n.id);
- });
-
- const completedCount = nonOptionalNodes.filter(n => n.data?.state === "completed" || n.data?.state === "fully-completed").length;
- const totalNodes = nonOptionalNodes.length;
- const progress = totalNodes > 0 ? Math.round((completedCount / totalNodes) * 100) : 0;
-
- // Star counter
- const fullyCompleteCount = nodes.filter(n => n.type === "topic" && n.data?.state === "fully-completed").length;
- const totalOptionalCount = getAllOptionalNodeIds(nodes).length;
-
- // Node types
- const nodeTypes = {
- topic: (props) => ,
- task: (props) => ,
- background: BackgroundNode,
- debug: DebugNode,
- };
-
+import { LearningMap } from "./LearningMap";
+import { RoadmapData, RoadmapState } from "./types";
+
+export function HyperbookLearningmap({
+ roadmapData,
+ language,
+ onChange,
+ initialState
+}: {
+ roadmapData: string | RoadmapData;
+ language?: string;
+ onChange?: (state: RoadmapState) => void;
+ initialState?: RoadmapState;
+
+}) {
return (
- {/* Header */}
-
-
-
- {t.progress}
- {progress}%
-
-
- {fullyCompleteCount} / {totalOptionalCount}
-
-
-
-
- {t.topicsCompleted
- .replace("{completed}", completedCount)
- .replace("{total}", totalNodes)}
-
-
-
-
- {/* Roadmap */}
-
-
-
-
-
-
- {/* Legend */}
-
-
-
-
- {t.available}
-
-
-
- {t.inProgressLegend}
-
-
-
- {t.completed}
-
-
-
- {t.locked}
-
-
-
-
- {/* Drawers */}
-
+
+
+
);
}
-export function HyperbookLearningmap({ roadmapData, nodeState, language, x, y, zoom }) {
- return (
-
-
-
- );
-}
diff --git a/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx b/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx
new file mode 100644
index 00000000..c77f6351
--- /dev/null
+++ b/packages/web-component-learningmap/src/HyperbookLearningmapEditor.tsx
@@ -0,0 +1,27 @@
+import {
+ ReactFlowProvider,
+} from "@xyflow/react";
+import { LearningMapEditor } from "./LearningMapEditor";
+import { RoadmapData } from "./types";
+
+export function HyperbookLearningmapEditor({
+ roadmapData,
+ language,
+ onChange
+}: {
+ roadmapData: string | RoadmapData;
+ language?: string;
+ onChange?: (data: RoadmapData) => void;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/LearningMap.tsx b/packages/web-component-learningmap/src/LearningMap.tsx
new file mode 100644
index 00000000..bb52c5ec
--- /dev/null
+++ b/packages/web-component-learningmap/src/LearningMap.tsx
@@ -0,0 +1,280 @@
+import { Controls, Edge, Node, Panel, ReactFlow, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react";
+import { ImageNode } from "./nodes/ImageNode";
+import { TaskNode } from "./nodes/TaskNode";
+import { TextNode } from "./nodes/TextNode";
+import { TopicNode } from "./nodes/TopicNode";
+import { NodeData, RoadmapData, RoadmapState, Settings } from "./types";
+import { useCallback, useEffect, useState } from "react";
+import { parseRoadmapData } from "./helper";
+import { Drawer } from "./Drawer";
+import { ProgressTracker } from "./ProgressTracker";
+import { getTranslations } from "./translations";
+
+const nodeTypes = {
+ topic: TopicNode,
+ task: TaskNode,
+ image: ImageNode,
+ text: TextNode,
+};
+
+const getStateMap = (nodes: Node[]) => {
+ const stateMap: Record = {};
+ nodes.forEach(n => {
+ if (n.data?.state) {
+ stateMap[n.id] = n.data.state;
+ }
+ });
+ return stateMap;
+}
+
+const isCompleteState = (state: string) => state === 'completed' || state === 'mastered';
+
+const updateNodesStates = (nodes: Node[]) => {
+ for (let i = 0; i < 2; i++) {
+ const stateMap = getStateMap(nodes);
+ for (const node of nodes) {
+ node.data.state = node.data?.state || 'locked';
+ // check unlock conditions
+ if (node.data?.unlock?.after) {
+ const unlocked = node.data.unlock.after.every((depId: string) => isCompleteState(stateMap[depId]));
+ if (unlocked) {
+ if (node.data.state === "locked") {
+ node.data.state = 'unlocked';
+ }
+ } else {
+ node.data.state = 'locked';
+ }
+ }
+ if (node.data?.unlock?.date) {
+ const unlockDate = new Date(node.data.unlock.date);
+ const now = new Date();
+ if (now >= unlockDate) {
+ if (node.data.state === "locked") {
+ node.data.state = 'unlocked';
+ }
+ } else {
+ node.data.state = 'locked';
+ }
+ }
+ if (!node.data?.unlock?.after && !node.data?.unlock?.date) {
+ if (node.data.state === "locked") {
+ node.data.state = 'unlocked';
+ }
+ }
+ if (node.type != "topic") continue;
+ if (node.data?.completion?.needs) {
+ const noNeeds = node.data.completion.needs.every((need: string) => isCompleteState(stateMap[need]));
+ if (node.data.state === "unlocked" && noNeeds) {
+ node.data.state = 'completed';
+ }
+ } else if (!node.data?.completion?.needs && node.data.state === "unlocked") {
+ node.data.state = 'completed';
+ }
+ if (node.data?.completion?.optional) {
+ const noOptional = node.data.completion.optional.every((opt: string) => isCompleteState(stateMap[opt]));
+ if (node.data.state === "completed" && noOptional) {
+ node.data.state = 'mastered';
+ }
+ } else if (!node.data?.completion?.optional && node.data.state === "completed") {
+ node.data.state = 'mastered';
+ }
+ }
+ }
+
+ return nodes;
+};
+
+const isInteractableNode = (node: Node) => {
+ return node.type === "task" || node.type === "topic";
+}
+
+const countCompletedNodes = (nodes: Node[]) => {
+ let completed = 0;
+ let mastered = 0;
+ let total = 0;
+ nodes.forEach(n => {
+ if (n.type === "task" || n.type === "topic") {
+ total++;
+ if (n.data?.state === 'completed') {
+ completed++;
+ }
+ else if (n.data?.state === 'mastered') {
+ completed++;
+ mastered++;
+ }
+ }
+ });
+ return { completed, mastered, total };
+}
+
+export function LearningMap({
+ roadmapData,
+ onChange,
+ language = "en",
+ initialState
+}: {
+ roadmapData: string | RoadmapData;
+ language?: string;
+ onChange?: (state: RoadmapState) => void;
+ initialState?: RoadmapState;
+}) {
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [selectedNode, setSelectedNode] = useState | null>(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [settings, setSettings] = useState();
+ const { fitView, getViewport, setViewport } = useReactFlow();
+
+ // Use language from settings if available, otherwise use prop
+ const effectiveLanguage = settings?.language || language;
+ const t = getTranslations(effectiveLanguage);
+
+ const { completed, mastered, total } = countCompletedNodes(nodes);
+
+ const parsedRoadmap = parseRoadmapData(roadmapData);
+
+ console.log(initialState);
+
+ useEffect(() => {
+ async function loadRoadmap() {
+ const nodesArr = Array.isArray(parsedRoadmap?.nodes) ? parsedRoadmap.nodes : [];
+ const edgesArr = Array.isArray(parsedRoadmap?.edges) ? parsedRoadmap.edges : [];
+
+ setSettings(parsedRoadmap?.settings || {});
+
+ let rawNodes = nodesArr.map((n) => {
+ let zIndex;
+ if (n.type === "image") {
+ zIndex = -2
+ } else if (n.type === "text") {
+ zIndex = -1
+ }
+ return {
+ ...n,
+ draggable: false,
+ connectable: false,
+ selectable: isInteractableNode(n),
+ focusable: isInteractableNode(n),
+ zIndex,
+ data: {
+ ...n.data,
+ state: initialState?.nodes?.[n.id]?.state,
+ }
+ }
+ });
+
+ rawNodes = updateNodesStates(rawNodes);
+
+ setViewport({
+ x: initialState?.x || 0,
+ y: initialState?.y || 0,
+ zoom: initialState?.zoom || 1,
+ });
+ setEdges(edgesArr);
+ setNodes(rawNodes);
+ }
+ loadRoadmap();
+ }, [roadmapData, initialState]);
+
+ const onNodeClick = useCallback((_: any, node: Node, focus: boolean = false) => {
+ if (!isInteractableNode(node)) return;
+ setSelectedNode(node);
+ setDrawerOpen(true);
+
+ if (focus) {
+ fitView({ nodes: [node], duration: 150 });
+ }
+ }, [fitView]);
+
+ const closeDrawer = useCallback(() => {
+ setDrawerOpen(false);
+ setSelectedNode(null);
+ }, []);
+
+ const updateNode = useCallback(
+ (updatedNode: Node) => {
+ setNodes((nds) => {
+ let newNodes = nds.map((n) => (n.id === updatedNode.id ? updatedNode : n))
+ newNodes = updateNodesStates(newNodes);
+ return newNodes;
+ }
+ );
+ setSelectedNode(updatedNode);
+ },
+ [setNodes]
+ );
+
+ useEffect(() => {
+ const viewport = getViewport();
+ const minimalState: RoadmapState = { nodes: {}, x: viewport.x, y: viewport.y, zoom: viewport.zoom };
+ nodes.forEach((n) => {
+ if (n.data.state && n.type === "task") {
+ minimalState.nodes[n.id] = { state: n.data.state };
+ }
+ });
+ if (onChange) {
+ onChange(minimalState);
+ } else {
+ const root = document.querySelector("hyperbook-learningmap");
+ if (root) {
+ root.dispatchEvent(new CustomEvent("change", { detail: minimalState }));
+ }
+ }
+ }, [nodes]);
+
+ const defaultEdgeOptions = {
+ animated: false,
+ style: {
+ stroke: "#94a3b8",
+ strokeWidth: 2,
+ },
+ type: "default",
+ };
+
+ return (
+
+
{
+ const className = [];
+ if (n.data?.color) {
+ className.push(n.data.color);
+ }
+ className.push(n.data?.state);
+ return {
+ ...n,
+ selected: selectedNode?.id === n.id,
+ className: className.join(" "),
+ };
+ })}
+ edges={edges}
+ onEdgesChange={onEdgesChange}
+ onNodeClick={onNodeClick}
+ onNodesChange={onNodesChange}
+ nodeTypes={nodeTypes}
+ fitView
+ proOptions={{ hideAttribution: true }}
+ defaultEdgeOptions={defaultEdgeOptions}
+ nodesDraggable={false}
+ nodesConnectable={false}
+ >
+ {settings?.title && (
+
+
+ {settings.title}
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/web-component-learningmap/src/LearningMapEditor.tsx b/packages/web-component-learningmap/src/LearningMapEditor.tsx
new file mode 100644
index 00000000..a20287d8
--- /dev/null
+++ b/packages/web-component-learningmap/src/LearningMapEditor.tsx
@@ -0,0 +1,730 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ ReactFlow,
+ Controls,
+ useNodesState,
+ useEdgesState,
+ ColorMode,
+ useReactFlow,
+ Node,
+ addEdge,
+ Connection,
+ Edge,
+ Background,
+ ControlButton,
+ OnNodesChange,
+ OnEdgesChange,
+ getNodesBounds,
+ getViewportForBounds,
+ Panel,
+ OnSelectionChangeFunc,
+ useEdges,
+} from "@xyflow/react";
+import { toSvg } from "html-to-image";
+import { EditorDrawer } from "./EditorDrawer";
+import { EdgeDrawer } from "./EdgeDrawer";
+import { TaskNode } from "./nodes/TaskNode";
+import { TopicNode } from "./nodes/TopicNode";
+import { ImageNode } from "./nodes/ImageNode";
+import { TextNode } from "./nodes/TextNode";
+import { RoadmapData, NodeData, ImageNodeData, TextNodeData, Settings } from "./types";
+import { SettingsDrawer } from "./SettingsDrawer";
+import FloatingEdge from "./FloatingEdge";
+import { EditorToolbar } from "./EditorToolbar";
+import { parseRoadmapData } from "./helper";
+import { LearningMap } from "./LearningMap";
+import { Info, Redo, Undo, RotateCw, ShieldAlert } from "lucide-react";
+import useUndoable from "./useUndoable";
+import { MultiNodePanel } from "./MultiNodePanel";
+import { getTranslations } from "./translations";
+
+const nodeTypes = {
+ topic: TopicNode,
+ task: TaskNode,
+ image: ImageNode,
+ text: TextNode,
+};
+
+const edgeTypes = {
+ floating: FloatingEdge
+};
+
+
+export function LearningMapEditor({
+ roadmapData,
+ language = "en",
+ onChange,
+}: {
+ roadmapData: string | RoadmapData;
+ language?: string;
+ onChange?: (data: RoadmapData) => void;
+}) {
+ const { screenToFlowPosition, getViewport, setViewport } = useReactFlow();
+ const [roadmapState, setRoadmapState, { undo, redo, canUndo, canRedo, reset, resetInitialState }] = useUndoable({
+ settings: {},
+ version: 1,
+ });
+
+ const [saved, setSaved] = useState(true);
+ const [didUndoRedo, setDidUndoRedo] = useState(false);
+ const [previewMode, setPreviewMode] = useState(false);
+ const [debugMode, setDebugMode] = useState(false);
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [settings, setSettings] = useState({ background: { color: "#ffffff" } });
+
+ // Use language from settings if available, otherwise use prop
+ const effectiveLanguage = settings?.language || language;
+ const t = getTranslations(effectiveLanguage);
+
+ const keyboardShortcuts = [
+ { action: t.shortcuts.save, shortcut: "Ctrl+S" },
+ { action: t.shortcuts.undo, shortcut: "Ctrl+Z" },
+ { action: t.shortcuts.redo, shortcut: "Ctrl+Y or Ctrl+Shift+Z" },
+ { action: t.shortcuts.addTaskNode, shortcut: "Ctrl+A" },
+ { action: t.shortcuts.addTopicNode, shortcut: "Ctrl+O" },
+ { action: t.shortcuts.addImageNode, shortcut: "Ctrl+I" },
+ { action: t.shortcuts.addTextNode, shortcut: "Ctrl+X" },
+ { action: t.shortcuts.deleteNodeEdge, shortcut: "Delete" },
+ { action: t.shortcuts.togglePreviewMode, shortcut: "Ctrl+P" },
+ { action: t.shortcuts.toggleDebugMode, shortcut: "Ctrl+D" },
+ { action: t.shortcuts.selectMultipleNodes, shortcut: "Ctrl+Click or Shift+Drag" },
+ { action: t.shortcuts.showHelp, shortcut: "Ctrl+? or Help Button" },
+ ];
+
+ const [helpOpen, setHelpOpen] = useState(false);
+ const [colorMode] = useState("light");
+ const [selectedNodeId, setSelectedNodeId] = useState | null>(null);
+ const [selectedNodeIds, setSelectedNodeIds] = useState([]);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
+ const [nextNodeId, setNextNodeId] = useState(1);
+
+ // Debug settings state
+ const [showCompletionNeeds, setShowCompletionNeeds] = useState(true);
+ const [showCompletionOptional, setShowCompletionOptional] = useState(true);
+ const [showUnlockAfter, setShowUnlockAfter] = useState(true);
+
+ // Edge drawer state
+ const [selectedEdge, setSelectedEdge] = useState(null);
+ const [edgeDrawerOpen, setEdgeDrawerOpen] = useState(false);
+
+ // Track Shift key state
+ const [shiftPressed, setShiftPressed] = useState(false);
+
+ useEffect(() => {
+ const parsedRoadmap = parseRoadmapData(roadmapData);
+ loadRoadmapStateIntoReactFlowState(parsedRoadmap);
+ resetInitialState(parsedRoadmap);
+ }, [roadmapData])
+
+ const loadRoadmapStateIntoReactFlowState = useCallback((roadmapState: RoadmapData) => {
+ const nodesArr = Array.isArray(roadmapState?.nodes) ? roadmapState.nodes : [];
+ const edgesArr = Array.isArray(roadmapState?.edges) ? roadmapState.edges : [];
+
+ setSettings(roadmapState?.settings || { background: { color: "#ffffff" } });
+
+ const rawNodes = nodesArr.map((n) => ({
+ ...n,
+ draggable: true,
+ data: { ...n.data },
+ }));
+
+ setEdges(edgesArr);
+ setNodes(rawNodes);
+
+ // Calculate next node ID
+ if (nodesArr.length > 0) {
+ const maxId = Math.max(
+ ...nodesArr
+ .map((n) => parseInt(n.id.replace(/\D/g, ""), 10))
+ .filter((id) => !isNaN(id))
+ );
+ setNextNodeId(maxId + 1);
+ }
+ }, [setNodes, setEdges, setSettings]);
+
+ useEffect(() => {
+ if (didUndoRedo) {
+ setDidUndoRedo(false);
+ loadRoadmapStateIntoReactFlowState(roadmapState);
+ }
+ }, [roadmapState, didUndoRedo, loadRoadmapStateIntoReactFlowState]);
+
+ useEffect(() => {
+ const newEdges: Edge[] = edges.filter((e) => !e.id.startsWith("debug-"));
+ if (debugMode) {
+ nodes.forEach((node) => {
+ if (showCompletionNeeds && node.type === "topic" && node.data?.completion?.needs) {
+ node.data.completion.needs.forEach((needId: string) => {
+ const edgeId = `debug-edge-${needId}-to-${node.id}`;
+ newEdges.push({
+ id: edgeId,
+ target: needId,
+ source: node.id,
+ animated: true,
+ style: { stroke: "#f97316", strokeWidth: 2, strokeDasharray: "5,5" },
+ type: "floating",
+ });
+ });
+ }
+ if (showCompletionOptional && node.data?.completion?.optional) {
+ node.data.completion.optional.forEach((optionalId: string) => {
+ const edgeId = `debug-edge-optional-${optionalId}-to-${node.id}`;
+ newEdges.push({
+ id: edgeId,
+ target: optionalId,
+ source: node.id,
+ animated: true,
+ style: { stroke: "#eab308", strokeWidth: 2, strokeDasharray: "5,5" },
+ type: "floating",
+ });
+ });
+ }
+ });
+ nodes.forEach((node) => {
+ if (showUnlockAfter && node.data.unlock?.after) {
+ node.data.unlock.after.forEach((unlockId: string) => {
+ const edgeId = `debug-edge-${unlockId}-to-${node.id}`;
+ newEdges.push({
+ id: edgeId,
+ target: unlockId,
+ source: node.id,
+ animated: true,
+ style: { stroke: "#10b981", strokeWidth: 2, strokeDasharray: "5,5" },
+ type: "floating",
+ });
+ });
+ }
+ });
+ }
+ setEdges(newEdges);
+ }, [nodes, setEdges, debugMode, showCompletionNeeds, showCompletionOptional, showUnlockAfter]);
+
+ // Event handlers
+ const onNodeClick = useCallback((_: any, node: Node) => {
+ setSelectedNodeId(node.id);
+ setDrawerOpen(true);
+ }, []);
+
+ const onEdgeClick = useCallback((_: any, edge: Edge) => {
+ setSelectedEdge(edge);
+ setEdgeDrawerOpen(true);
+ }, []);
+
+ const onConnect = useCallback(
+ (connection: Connection) => {
+ setEdges((eds) => addEdge(connection, eds));
+ setSaved(false);
+ },
+ [setEdges, setSaved]
+ );
+
+ const toggleDebugMode = useCallback(() => {
+ setDebugMode((mode) => !mode);
+ }, [setDebugMode]);
+
+ const closeDrawer = useCallback(() => {
+ setDrawerOpen(false);
+ setSelectedNodeId(null);
+ setEdgeDrawerOpen(false);
+ setSelectedEdge(null);
+ setSettingsDrawerOpen(false)
+ }, []);
+
+ const updateNode = useCallback(
+ (updatedNode: Node) => {
+ setNodes((nds) =>
+ nds.map((n) => (n.id === updatedNode.id ? updatedNode : n))
+ );
+ setSaved(false);
+ },
+ [setNodes, setSaved]
+ );
+
+ const updateNodes = useCallback(
+ (updatedNodes: Node[]) => {
+ setNodes((nds) => nds.map(n => {
+ const updated = updatedNodes.find(un => un.id === n.id);
+ return updated ? updated : n;
+ }));
+ setSaved(false);
+ },
+ [setNodes, setSaved]
+ );
+
+ const updateEdge = useCallback(
+ (updatedEdge: Edge) => {
+ setEdges((eds) =>
+ eds.map((e) => (e.id === updatedEdge.id ? { ...e, ...updatedEdge } : e))
+ );
+ setSelectedEdge(updatedEdge);
+ setSaved(false);
+ },
+ [setEdges, setSelectedEdge, setSaved]
+ );
+
+ // Delete selected edge
+ const deleteEdge = useCallback(() => {
+ if (!selectedEdge) return;
+ setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
+ setSaved(false);
+ closeDrawer();
+ }, [selectedEdge, setEdges, closeDrawer]);
+
+ const deleteNode = useCallback(() => {
+ if (!selectedNodeId) return;
+ setNodes((nds) => nds.filter((n) => n.id !== selectedNodeId));
+ setEdges((eds) =>
+ eds.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId)
+ );
+ setSaved(false);
+ closeDrawer();
+ }, [selectedNodeId, setNodes, setEdges, closeDrawer, setSaved]);
+
+ const addNewNode = useCallback(
+ (type: "task" | "topic" | "image" | "text") => {
+ const centerPos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
+ if (type === "task") {
+ const newNode: Node = {
+ id: `node${nextNodeId}`,
+ type,
+ position: centerPos,
+ data: {
+ label: t.newTask,
+ summary: "",
+ description: "",
+ },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ setNextNodeId((id) => id + 1);
+ } else if (type === "topic") {
+ const newNode: Node = {
+ id: `node${nextNodeId}`,
+ type,
+ position: centerPos,
+ data: {
+ label: t.newTopic,
+ summary: "",
+ description: "",
+ },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ setNextNodeId((id) => id + 1);
+ }
+ else if (type === "image") {
+ const newNode: Node = {
+ id: `background-node${nextNodeId}`,
+ type,
+ zIndex: -2,
+ position: centerPos,
+ data: {
+ src: "",
+ },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ setNextNodeId((id) => id + 1);
+ } else if (type === "text") {
+ const newNode: Node = {
+ id: `background-node${nextNodeId}`,
+ type,
+ position: centerPos,
+ zIndex: -1,
+ data: {
+ text: t.backgroundTextDefault,
+ fontSize: 32,
+ color: "#e5e7eb",
+ },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ setNextNodeId((id) => id + 1);
+ }
+ setSaved(false);
+ },
+ [nextNodeId, screenToFlowPosition, setNodes, setSaved, t]
+ );
+
+ const handleSave = useCallback(() => {
+ const roadmapData: RoadmapData = {
+ nodes: nodes.map((n) => ({
+ id: n.id,
+ type: n.type,
+ position: n.position,
+ data: n.data,
+ })),
+ edges: edges.filter((e) => !e.id.startsWith("debug-"))
+ .map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ sourceHandle: e.sourceHandle,
+ targetHandle: e.targetHandle,
+ animated: e.animated,
+ type: e.type,
+ style: e.style,
+ })),
+ settings,
+ version: 1
+ };
+
+ setRoadmapState(roadmapData);
+ setSaved(true);
+
+ if (onChange) {
+ onChange(roadmapData);
+ return;
+ } else {
+ const root = document.querySelector("hyperbook-learningmap-editor");
+ if (root) {
+ root.dispatchEvent(new CustomEvent("change", { detail: roadmapData }));
+ }
+ }
+ }, [nodes, edges, settings]);
+
+ const togglePreviewMode = useCallback(() => {
+ handleSave();
+ setPreviewMode((mode) => {
+ const newMode = !mode;
+ if (newMode) {
+ setDebugMode(false);
+ closeDrawer();
+ }
+ return newMode;
+ });
+ }, [setPreviewMode, handleSave]);
+
+ const handleDownload = useCallback(() => {
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(roadmapState, null, 2));
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.setAttribute("href", dataStr);
+ downloadAnchorNode.setAttribute("download", `${roadmapState.settings.title ?? new Date().toString()}.learningmap.json`);
+ document.body.appendChild(downloadAnchorNode); // required for firefox
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+ }, [roadmapState]);
+
+ const defaultEdgeOptions = {
+ animated: false,
+ style: {
+ stroke: "#94a3b8",
+ strokeWidth: 2,
+ },
+ type: "default",
+ };
+
+ const handleExportSVG = useCallback(async () => {
+ const nodesBounds = getNodesBounds(nodes);
+ const imageWidth = nodesBounds.width;
+ const imageHeight = nodesBounds.height;
+ let viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.1, 5);
+
+ const dom = document.querySelector(".react-flow__viewport") as HTMLElement;
+ if (!dom) return;
+
+ toSvg(dom, {
+ backgroundColor: settings?.background?.color || "#ffffff",
+ width: imageWidth,
+ height: imageHeight,
+ style: {
+ transform: `translate(${viewport.x / 2.0}px, ${viewport.y / 2.0}px) scale(${viewport.zoom})`,
+ width: `${imageWidth}px`,
+ height: `${imageHeight}px`,
+ }
+ }).then((dataUrl) => {
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.setAttribute("href", dataUrl);
+ downloadAnchorNode.setAttribute("download", "roadmap.svg");
+ document.body.appendChild(downloadAnchorNode); // required for firefox
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+
+ // Restore old viewport
+ }).catch((err) => {
+ alert(t.failedToExportSVG + err.message);
+ });
+ }, [nodes, roadmapState, t]);
+
+ const handleOpen = useCallback(() => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.onchange = (e: any) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ if (!window.confirm(t.openFileWarning)) {
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ try {
+ const content = evt.target?.result;
+ if (typeof content === 'string') {
+ const json = JSON.parse(content);
+ setRoadmapState(json);
+ loadRoadmapStateIntoReactFlowState(json);
+ }
+ } catch (err) {
+ alert(t.failedToLoadFile);
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ }, [setRoadmapState, setDidUndoRedo, t]);
+
+ // Toolbar handler wrappers for EditorToolbar props
+ const handleOpenSettingsDrawer = useCallback(() => setSettingsDrawerOpen(true), []);
+ const handleSetShowCompletionNeeds = useCallback((checked: boolean) => setShowCompletionNeeds(checked), []);
+ const handleSetShowCompletionOptional = useCallback((checked: boolean) => setShowCompletionOptional(checked), []);
+ const handleSetShowUnlockAfter = useCallback((checked: boolean) => setShowUnlockAfter(checked), []);
+
+ const handleNodesChange: OnNodesChange = useCallback(
+ (changes) => {
+ setSaved(false);
+ onNodesChange(changes);
+ },
+ [onNodesChange, setSaved]
+ );
+
+ const handleEdgesChange: OnEdgesChange = useCallback(
+ (changes) => {
+ setSaved(false);
+ onEdgesChange(changes);
+ },
+ [onEdgesChange, setSaved]
+ );
+
+ const handleUndo = useCallback(() => {
+ if (canUndo) {
+ undo();
+ setDidUndoRedo(true);
+ }
+ }, [canUndo, undo]);
+
+ const handleRedo = useCallback(() => {
+ if (canRedo) {
+ redo();
+ setDidUndoRedo(true);
+ }
+ }, [canRedo, redo]);
+
+ const handleReset = useCallback(() => {
+ reset();
+ setDidUndoRedo(true);
+ }, [reset]);
+
+ const handleSelectionChange: OnSelectionChangeFunc = useCallback(
+ ({ nodes: selectedNodes }) => {
+ if (selectedNodes.length > 1) {
+ setSelectedNodeIds(selectedNodes.map(n => n.id));
+ } else {
+ setSelectedNodeIds([]);
+ }
+ },
+ [setSelectedNodeIds]
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Shift") setShiftPressed(true);
+ //save shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key === 's' && !e.shiftKey) {
+ e.preventDefault();
+ handleSave();
+ }
+ // undo shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ handleUndo();
+ }
+ // redo shortcut
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
+ e.preventDefault();
+ handleRedo();
+ }
+ // add task node shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && !e.shiftKey) {
+ e.preventDefault();
+ addNewNode("task");
+ }
+ // add topic node shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'o' && !e.shiftKey) {
+ e.preventDefault();
+ addNewNode("topic");
+ }
+ // add image node shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'i' && !e.shiftKey) {
+ e.preventDefault();
+ addNewNode("image");
+ }
+ // add text node shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'x' && !e.shiftKey) {
+ e.preventDefault();
+ addNewNode("text");
+ }
+
+ if ((e.ctrlKey || e.metaKey) && (e.key === '?' || (e.shiftKey && e.key === '/'))) {
+ e.preventDefault();
+ setHelpOpen(h => !h);
+ }
+ //preview toggle shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p' && !e.shiftKey) {
+ e.preventDefault();
+ togglePreviewMode();
+ }
+ //debug toggle shortcut
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd' && !e.shiftKey) {
+ e.preventDefault();
+ toggleDebugMode();
+ }
+ // Dismiss with Escape
+ if (helpOpen && e.key === 'Escape') {
+ setHelpOpen(false);
+ }
+ };
+ const handleKeyUp = (e: KeyboardEvent) => {
+ if (e.key === "Shift") setShiftPressed(false);
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, [handleSave, handleUndo, handleRedo, addNewNode, helpOpen, setHelpOpen, togglePreviewMode, toggleDebugMode]);
+
+ return (
+ <>
+
+ {previewMode && }
+ {!previewMode && <>
+
+
{
+ const className = [];
+ if (n.data?.color) {
+ className.push(n.data.color);
+ }
+ return {
+ ...n,
+ className: className.join(" ")
+ };
+ })}
+ edges={edges}
+ onEdgesChange={handleEdgesChange}
+ onNodeDoubleClick={onNodeClick}
+ onEdgeDoubleClick={onEdgeClick}
+ onNodesChange={handleNodesChange}
+ onConnect={onConnect}
+ onSelectionChange={handleSelectionChange}
+ nodeTypes={nodeTypes}
+ selectionOnDrag={false}
+ edgeTypes={edgeTypes}
+ fitView
+ snapToGrid={!shiftPressed}
+ proOptions={{ hideAttribution: true }}
+ defaultEdgeOptions={defaultEdgeOptions}
+ nodesDraggable={true}
+ elevateNodesOnSelect={false}
+ nodesConnectable={true}
+ colorMode={colorMode}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ setHelpOpen(true)}>
+
+
+
+ {!saved && { handleSave(); }}>
+
+ }
+ {selectedNodeIds.length > 1 && selectedNodeIds.includes(n.id))} onUpdate={updateNodes} />}
+
+
+ n.id === selectedNodeId)}
+ isOpen={drawerOpen}
+ onClose={closeDrawer}
+ onUpdate={updateNode}
+ onDelete={deleteNode}
+ language={effectiveLanguage}
+ />
+
+
+
+ >
+ }
+ >
+ );
+}
diff --git a/packages/web-component-learningmap/src/MultiNodePanel.tsx b/packages/web-component-learningmap/src/MultiNodePanel.tsx
new file mode 100644
index 00000000..4843c232
--- /dev/null
+++ b/packages/web-component-learningmap/src/MultiNodePanel.tsx
@@ -0,0 +1,146 @@
+import { Node, Panel } from "@xyflow/react";
+import { NodeData } from "./types";
+import { FC } from "react";
+import { AlignCenterVertical, AlignCenterHorizontal, AlignEndHorizontal, AlignEndVertical, AlignStartVertical, AlignStartHorizontal, RulerDimensionLine, AlignVerticalDistributeCenter, AlignHorizontalDistributeCenter } from "lucide-react";
+
+interface Props {
+ nodes: Node[];
+ onUpdate: (nodes: Node[]) => void;
+}
+
+export const MultiNodePanel: FC = ({ nodes, onUpdate }) => {
+
+ const alignLeftVertical = () => {
+ if (nodes.length < 2) return;
+ const minX = Math.min(...nodes.map(n => n.position.x));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, x: minX }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const alignCenterVertical = () => {
+ if (nodes.length < 2) return;
+ const avgX = nodes.reduce((sum, n) => sum + n.position.x + (n.width || n.measured.width) / 2, 0) / nodes.length;
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, x: avgX - (n.width || n.measured.width) / 2 }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const alignRightVertical = () => {
+ if (nodes.length < 2) return;
+ const maxX = Math.max(...nodes.map(n => n.position.x + (n.width || 0)));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, x: maxX - (n.width || n.measured.width) }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const alignLeft = () => {
+ if (nodes.length < 2) return;
+ const minY = Math.min(...nodes.map(n => n.position.y));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, y: minY }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const alignCenter = () => {
+ if (nodes.length < 2) return;
+ const avgY = nodes.reduce((sum, n) => sum + n.position.y + (n.height || n.measured.height) / 2, 0) / nodes.length;
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, y: avgY - (n.height || n.measured.height) / 2 }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const alignRight = () => {
+ if (nodes.length < 2) return;
+ const maxY = Math.max(...nodes.map(n => n.position.y + (n.height || 0)));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ position: { ...n.position, y: maxY - (n.height || n.measured.height) }
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const distributeVertical = () => {
+ if (nodes.length < 3) return;
+ // Improved vertical distribution: ensures equal gaps between nodes regardless of node heights
+ const sortedNodes = [...nodes].sort((a, b) => a.position.y - b.position.y);
+ const minY = sortedNodes[0].position.y;
+ const totalNodesHeight = sortedNodes.reduce((sum, n) => sum + (n.height || n.measured.height), 0);
+ const maxY = sortedNodes[sortedNodes.length - 1].position.y;
+ const availableSpace = (maxY - minY) + (sortedNodes[sortedNodes.length - 1].height || sortedNodes[sortedNodes.length - 1].measured.height) - totalNodesHeight;
+ const gap = sortedNodes.length > 1 ? availableSpace / (sortedNodes.length - 1) : 0;
+ let currentY = minY;
+ const updatedNodes = sortedNodes.map((n, i) => {
+ const updatedNode = {
+ ...n,
+ position: { ...n.position, y: currentY }
+ };
+ currentY += (n.height || n.measured.height) + gap;
+ return updatedNode;
+ });
+ onUpdate(updatedNodes);
+ };
+
+ const distributeHorizontal = () => {
+ if (nodes.length < 3) return;
+ const sortedNodes = [...nodes].sort((a, b) => a.position.x - b.position.x);
+ const minX = sortedNodes[0].position.x;
+ const totalNodesWidth = sortedNodes.reduce((sum, n) => sum + (n.width || n.measured.width), 0);
+ const maxX = sortedNodes[sortedNodes.length - 1].position.x;
+ const availableSpace = (maxX - minX) + (sortedNodes[sortedNodes.length - 1].width || sortedNodes[sortedNodes.length - 1].measured.width) - totalNodesWidth;
+ const gap = sortedNodes.length > 1 ? availableSpace / (sortedNodes.length - 1) : 0;
+ let currentX = minX;
+ const updatedNodes = sortedNodes.map((n, i) => {
+ const updatedNode = {
+ ...n,
+ position: { ...n.position, x: currentX }
+ };
+ currentX += (n.width || n.measured.width) + gap;
+ return updatedNode;
+ });
+ onUpdate(updatedNodes);
+ };
+
+ const sameWidth = () => {
+ if (nodes.length < 2) return;
+ const maxWidth = Math.max(...nodes.map(n => n.width || n.measured.width));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ width: maxWidth
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ const sameHeight = () => {
+ if (nodes.length < 2) return;
+ const maxHeight = Math.max(...nodes.map(n => n.height || n.measured.height));
+ const updatedNodes = nodes.map(n => ({
+ ...n,
+ height: maxHeight
+ }));
+ onUpdate(updatedNodes);
+ };
+
+ return
+
+
+
+
+
+
+ {nodes.length > 2 && }
+ {nodes.length > 2 && }
+
+
+ ;
+}
diff --git a/packages/web-component-learningmap/src/ProgressTracker.tsx b/packages/web-component-learningmap/src/ProgressTracker.tsx
new file mode 100644
index 00000000..fbd1181b
--- /dev/null
+++ b/packages/web-component-learningmap/src/ProgressTracker.tsx
@@ -0,0 +1,27 @@
+import { CheckCircle } from "lucide-react";
+import StarCircle from "./icons/StarCircle";
+import { getTranslations } from "./translations";
+
+export const ProgressTracker = ({ completed, mastered, total, language = "en" }: { completed: number; mastered: number; total: number; language?: string }) => {
+ const t = getTranslations(language);
+ const progress = total > 0 ? (completed / total) * 100 : 0;
+
+ return (
+ <>
+
+
+ {completed} / {total}
+
+
+
{progress.toFixed(0)}%
+
+
+
+
+
+ {mastered}
+
+ >
+ );
+}
+
diff --git a/packages/web-component-learningmap/src/RotationInput.tsx b/packages/web-component-learningmap/src/RotationInput.tsx
new file mode 100644
index 00000000..04da8b3d
--- /dev/null
+++ b/packages/web-component-learningmap/src/RotationInput.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+
+interface RotationInputProps {
+ value: number;
+ onChange: (value: number) => void;
+}
+
+export function RotationInput({ value, onChange }: RotationInputProps) {
+ return (
+
+ );
+}
diff --git a/packages/web-component-learningmap/src/SettingsDrawer.tsx b/packages/web-component-learningmap/src/SettingsDrawer.tsx
new file mode 100644
index 00000000..498026e9
--- /dev/null
+++ b/packages/web-component-learningmap/src/SettingsDrawer.tsx
@@ -0,0 +1,84 @@
+import React, { useState, useEffect } from "react";
+import { X, Save } from "lucide-react";
+import { Settings } from "./types";
+import { ColorSelector } from "./ColorSelector";
+import { getTranslations } from "./translations";
+
+interface SettingsDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ settings: Settings;
+ onUpdate: (s: Settings) => void;
+ language?: string;
+}
+
+export const SettingsDrawer: React.FC = ({
+ isOpen,
+ onClose,
+ settings,
+ onUpdate,
+ language = "en",
+}) => {
+ const t = getTranslations(language);
+ const [localSettings, setLocalSettings] = useState(settings);
+
+ useEffect(() => {
+ setLocalSettings(settings);
+ }, [settings]);
+
+ if (!isOpen) return null;
+
+ const handleSave = () => {
+ onUpdate(localSettings);
+ onClose();
+ };
+
+ return (
+ <>
+
+
+
+
{t.backgroundSettings}
+
+
+
+
+
+
+ setLocalSettings(settings => ({ ...settings, title: e.target.value }))}
+ placeholder={t.placeholderNodeLabel}
+ />
+
+
+
+
+
+
+ setLocalSettings(settings => ({ ...settings, background: { ...settings.background, color } }))}
+ />
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/Video.tsx b/packages/web-component-learningmap/src/Video.tsx
new file mode 100644
index 00000000..81b2ab8b
--- /dev/null
+++ b/packages/web-component-learningmap/src/Video.tsx
@@ -0,0 +1,54 @@
+function isYoutubeUrl(url: string) {
+ return (
+ typeof url === "string" &&
+ (url.includes("youtube.com/watch?v=") || url.includes("youtu.be/"))
+ );
+}
+
+function getYoutubeEmbedUrl(url: string) {
+ if (url.includes("youtube.com/watch?v=")) {
+ const videoId = url.split("v=")[1].split("&")[0];
+ return `https://www.youtube-nocookie.com/embed/${videoId}`;
+ }
+ if (url.includes("youtu.be/")) {
+ const videoId = url.split("youtu.be/")[1].split("?")[0];
+ return `https://www.youtube-nocookie.com/embed/${videoId}`;
+ }
+ return url;
+}
+
+function getVideoMimeType(url: string) {
+ if (url.endsWith(".webm")) return "video/webm";
+ if (url.endsWith(".mp4")) return "video/mp4";
+ return "video/mp4";
+}
+
+export const Video: React.FC<{ url: string; title?: string }> = ({ url, title }) => {
+ if (isYoutubeUrl(url)) {
+ const embedUrl = getYoutubeEmbedUrl(url);
+ return (
+
+
+
+ );
+ } else {
+ const mimeType = getVideoMimeType(url);
+ return (
+
+ );
+ }
+};
diff --git a/packages/web-component-learningmap/src/autoLayoutElk.ts b/packages/web-component-learningmap/src/autoLayoutElk.ts
index d8aeeeaa..73cd091e 100644
--- a/packages/web-component-learningmap/src/autoLayoutElk.ts
+++ b/packages/web-component-learningmap/src/autoLayoutElk.ts
@@ -1,19 +1,20 @@
import ELK from "elkjs/lib/elk.bundled.js";
+import type { Node, Edge } from "@xyflow/react";
export async function getAutoLayoutedNodesElk(
- nodes,
- edges,
+ nodes: Node[],
+ edges: Edge[],
nodeWidth = 320,
nodeHeight = 120
) {
const elk = new ELK();
- const elkNodes = nodes.map((node) => ({
+ const elkNodes = nodes.map((node: Node) => ({
id: node.id,
width: nodeWidth,
height: nodeHeight,
...node,
}));
- const elkEdges = edges.map((edge) => ({
+ const elkEdges = edges.map((edge: Edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
@@ -29,10 +30,10 @@ export async function getAutoLayoutedNodesElk(
children: elkNodes,
edges: elkEdges,
};
- const layout = await elk.layout(elkGraph);
- return nodes.map((node) => {
+ const layout: any = await elk.layout(elkGraph);
+ return nodes.map((node: Node) => {
if (node.position) return node;
- const layoutNode = layout.children.find((n) => n.id === node.id);
+ const layoutNode = layout.children.find((n: any) => n.id === node.id);
return {
...node,
position: { x: layoutNode.x, y: layoutNode.y },
diff --git a/packages/web-component-learningmap/src/helper.ts b/packages/web-component-learningmap/src/helper.ts
new file mode 100644
index 00000000..50d7345e
--- /dev/null
+++ b/packages/web-component-learningmap/src/helper.ts
@@ -0,0 +1,86 @@
+import { Position, Node } from "@xyflow/react";
+import { RoadmapData } from "./types";
+
+// this helper function returns the intersection point
+// of the line between the center of the intersectionNode and the target node
+function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
+ // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a
+ const { width: intersectionNodeWidth, height: intersectionNodeHeight } =
+ intersectionNode.measured;
+ const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
+ const targetPosition = targetNode.internals.positionAbsolute;
+
+ const w = intersectionNodeWidth / 2;
+ const h = intersectionNodeHeight / 2;
+
+ const x2 = intersectionNodePosition.x + w;
+ const y2 = intersectionNodePosition.y + h;
+ const x1 = targetPosition.x + targetNode.measured.width / 2;
+ const y1 = targetPosition.y + targetNode.measured.height / 2;
+
+ const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
+ const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
+ const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
+ const xx3 = a * xx1;
+ const yy3 = a * yy1;
+ const x = w * (xx3 + yy3) + x2;
+ const y = h * (-xx3 + yy3) + y2;
+
+ return { x, y };
+}
+
+// returns the position (top,right,bottom or right) passed node compared to the intersection point
+function getEdgePosition(node: Node, intersectionPoint: any) {
+ const n = { ...node.internals.positionAbsolute, ...node };
+ const nx = Math.round(n.x);
+ const ny = Math.round(n.y);
+ const px = Math.round(intersectionPoint.x);
+ const py = Math.round(intersectionPoint.y);
+
+ if (px <= nx + 1) {
+ return Position.Left;
+ }
+ if (px >= nx + n.measured.width - 1) {
+ return Position.Right;
+ }
+ if (py <= ny + 1) {
+ return Position.Top;
+ }
+ if (py >= n.y + n.measured.height - 1) {
+ return Position.Bottom;
+ }
+
+ return Position.Top;
+}
+
+// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge
+export function getEdgeParams(source: Node, target: Node) {
+ const sourceIntersectionPoint = getNodeIntersection(source, target);
+ const targetIntersectionPoint = getNodeIntersection(target, source);
+
+ const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
+ const targetPos = getEdgePosition(target, targetIntersectionPoint);
+
+ return {
+ sx: sourceIntersectionPoint.x,
+ sy: sourceIntersectionPoint.y,
+ tx: targetIntersectionPoint.x,
+ ty: targetIntersectionPoint.y,
+ sourcePos,
+ targetPos,
+ };
+}
+
+export const parseRoadmapData = (
+ roadmapData: string | RoadmapData,
+): RoadmapData => {
+ if (typeof roadmapData !== "string") {
+ return roadmapData || {};
+ }
+ try {
+ return JSON.parse(roadmapData);
+ } catch (err) {
+ console.error("Failed to parse roadmap data:", err);
+ return { settings: { title: "New Roadmap" }, version: 1 };
+ }
+};
diff --git a/packages/web-component-learningmap/src/icons/StarCircle.tsx b/packages/web-component-learningmap/src/icons/StarCircle.tsx
new file mode 100644
index 00000000..109f5aa7
--- /dev/null
+++ b/packages/web-component-learningmap/src/icons/StarCircle.tsx
@@ -0,0 +1,23 @@
+import { FC, SVGProps } from "react";
+
+const StarCircle: FC> = (props) => (
+
+);
+
+export default StarCircle;
diff --git a/packages/web-component-learningmap/src/index.css b/packages/web-component-learningmap/src/index.css
index 33339f8b..a571e364 100644
--- a/packages/web-component-learningmap/src/index.css
+++ b/packages/web-component-learningmap/src/index.css
@@ -1,22 +1,4 @@
-.learningmap-footer {
- width: 100%;
- background: var(--color-nav);
- border-top: 1px solid var(--color-nav-border);
- padding: 8px 32px 16px 32px;
- z-index: 10;
- position: relative;
- box-sizing: border-box;
-}
-
-@media (max-width: 600px) {
- .learningmap-footer {
- padding-left: 8px;
- padding-right: 8px;
- }
-}
-
/* Container */
-
.hyperbook-learningmap-container {
width: 100%;
height: 100%;
@@ -24,356 +6,688 @@
flex-direction: column;
}
-
-.learningmap-header {
+/* Toolbar */
+.editor-toolbar {
width: 100%;
- background: var(--color-nav);
- border-bottom: 1px solid var(--color-nav-border);
- padding: 24px 32px 16px 32px;
+ background: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+ padding: 12px 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
z-index: 10;
- position: relative;
box-sizing: border-box;
- overflow-x: auto;
}
-@media (max-width: 600px) {
- .learningmap-header {
- padding-left: 8px;
- padding-right: 8px;
- }
+.toolbar-group {
+ display: flex;
+ gap: 8px;
+}
+
+.toolbar-button {
+ padding: 8px 16px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ background: white;
+ color: #1f2937;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.2s;
+}
+
+.toolbar-button:hover {
+ background: #f3f4f6;
+ border-color: #3b82f6;
+}
+
+.toolbar-button.primary {
+ background: #3b82f6;
+ color: white;
+ border-color: #3b82f6;
+}
+
+.toolbar-button.primary:hover {
+ background: #2563eb;
+ border-color: #2563eb;
+}
+
+.toolbar-button.active {
+ background: #3b82f6;
+ color: white;
+ border-color: #3b82f6;
}
-.learningmap-roadmap {
+.toolbar-button.active:hover {
+ background: #2563eb;
+ border-color: #2563eb;
+}
+
+.toolbar-button:disabled {
+ background: #f3f4f6;
+ color: #9ca3af;
+ border-color: #e5e7eb;
+ cursor: not-allowed;
+}
+
+/* Editor Canvas */
+.editor-canvas {
flex: 1;
min-height: 0;
position: relative;
width: 100%;
- background: transparent;
}
-/* Learning Node */
-.learning-node {
- padding: 16px 24px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- border: 2px solid;
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
- transition: all 0.3s ease;
- background: white;
+.react-flow__node {
+ color: #000;
}
.react-flow__node-background img {
max-width: none;
}
-.react-flow__node-task .learning-node {
- background: #f0f7ff;
- border-color: #3b82f6;
- border-radius: 16px;
+/* Drawer Styles */
+.drawer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ z-index: 1002;
+ animation: fadeIn 0.2s ease;
}
-.react-flow__node-topic .learning-node {
- background: #fffbe6;
- border-color: #f59e42;
- border-radius: 8px;
- font-size: 16px;
- /* slightly larger for emphasis */
- font-weight: 700;
- box-shadow: 0 2px 8px rgba(245, 158, 66, 0.08);
- padding-left: 18px;
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ }
+
+ to {
+ transform: translateX(0);
+ }
}
-.learning-node.optional {
- border: 2px dashed #38bdf8;
+.drawer {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 500px;
+ max-width: 90vw;
+ background: var(--color-nav, white);
+ box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1);
+ z-index: 1003;
+ display: flex;
+ flex-direction: column;
+ animation: slideIn 0.3s ease;
}
-.react-flow__node-topic .learning-node {
- border-left: 12px solid #f59e42;
+header.drawer-header {
+ background: none;
+ box-shadow: none;
}
-.learning-node.locked {
- background: #f3f4f6;
- border-color: #d1d5db;
+.drawer-header {
+ padding-top: 24px;
+ padding-left: 24px;
+ padding-right: 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.drawer-title {
+ font-size: 24px;
+ font-weight: 700;
+ margin: 0;
+
+ h2 {
+ text-decoration: none;
+ border: none;
+ }
}
-.learning-node.completed {
- background: #f0fdf4;
- border-color: #22c55e;
+.drawer-footer button {
+ width: 100%;
}
-.learning-node.available {
- background: white;
- border-color: #60a5fa;
+.close-button {
+ background: none;
+ border: none;
cursor: pointer;
+ padding: 4px;
+ color: var(--color-text, #6b7280);
+ transition: color 0.2s;
}
-.learning-node.available:hover {
- border-color: #2563eb;
- transform: translateY(-2px);
- box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+.close-button:hover {
+ color: var(--color-text, #1f2937);
}
-.node-header {
+.drawer-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+}
+
+.drawer-footer {
+ padding: 24px;
display: flex;
- align-items: center;
- gap: 8px;
+ gap: 12px;
+ justify-content: flex-end;
+}
+
+/* Form Styles */
+.form-group {
+ margin-bottom: 20px;
}
-.node-icon {
- width: 20px;
- height: 20px;
- flex-shrink: 0;
+.form-group label {
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 6px;
+ color: #374151;
}
-.learning-node.completed .node-icon {
- color: #16a34a;
+.form-group input[type="text"],
+.form-group input[type="date"],
+.form-group input[type="number"],
+.form-group input[type="color"],
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font-size: 14px;
+ background: white;
+ color: #1f2937;
+ box-sizing: border-box;
+ transition: border-color 0.2s;
}
-.learning-node.available .node-icon {
- color: #3b82f6;
+.form-group input[type="color"] {
+ height: 64px;
}
-.learning-node.started .node-icon {
- color: #f59e42;
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #3b82f6;
}
-.learning-node.locked .node-icon {
- color: #a1a1aa;
+.form-group textarea {
+ resize: vertical;
+ font-family: inherit;
}
-.node-label {
- font-weight: 600;
+/* Buttons */
+.primary-button {
+ padding: 10px 20px;
+ background: #3b82f6;
+ color: white;
+ border: none;
+ border-radius: 6px;
font-size: 14px;
- color: #1f2937;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: background 0.2s;
}
+.primary-button:hover {
+ background: #2563eb;
+}
-.star-counter {
- font-weight: 600;
- color: #f59e42;
+.secondary-button {
+ padding: 8px 16px;
+ background: white;
+ color: #1f2937;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 0.2s;
+ width: 100%;
+ justify-content: center;
}
-/* Progress Panel */
-.progress-panel {
- padding: 16px;
- margin: 16px;
+.secondary-button:hover {
+ background: #f3f4f6;
}
-.progress-section {
- margin-bottom: 8px;
+.danger-button {
+ padding: 10px 20px;
+ background: #ef4444;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: background 0.2s;
}
-.progress-header {
+.danger-button:hover {
+ background: #dc2626;
+}
+
+.drawer-button {
+ padding: 14px 20px;
+ cursor: pointer;
display: flex;
- justify-content: space-between;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: all 0.2s;
+ background: var(--color-nav, white);
+ color: var(--color-text, #1f2937);
+ border: 1px solid var(--color-nav-border, #d1d5db);
+ border-radius: 6px;
font-size: 14px;
- margin-bottom: 4px;
+ cursor: pointer;
}
-.progress-label {
- color: var(--color-text);
+.drawer-button:hover {
+ background: var(--color-spacer, #f3f4f6);
+ border-color: var(--color-brand, #3b82f6);
}
-.progress-value {
- font-weight: 600;
- color: var(--color-text);
+.drawer-button.locked {
+ background: var(--color-nav, #f3f4f6);
+ color: var(--color-text, #9ca3af);
+ border-color: var(--color-nav-border, #e5e7eb);
+ cursor: not-allowed;
}
-.progress-bar-container {
- width: 100%;
- background-color: var(--color-spacer);
- border: 1px solid var(--color-nav-border);
- border-radius: 9999px;
- height: 8px;
- overflow: hidden;
+.drawer-button.started {
+ background: #fef3c7;
+ border-color: #f59e42;
+ color: #b45309;
}
-.progress-bar-fill {
- background-color: var(--color-brand);
- height: 100%;
- border-radius: 9999px;
- transition: width 0.3s ease;
+.drawer-button.completed {
+ background: #d1fae5;
+ border-color: #10b981;
+ color: #065f46;
}
-.progress-text {
- font-size: 12px;
- margin-top: 4px;
+.drawer-button.mastered {
+ background: #d1fae5;
+ border-color: #10b981;
+ color: #065f46;
}
-/* Legend Panel */
-.legend-panel {
- border-radius: 0;
- box-shadow: none;
- padding: 12px 0 0 0;
- margin: 0;
- font-size: 12px;
- color: var(--color-text);
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: center;
- gap: 24px;
- justify-content: center;
+.icon-button {
+ padding: 6px;
+ background: none;
+ border: 1px solid var(--color-nav-border, #d1d5db);
+ border-radius: 4px;
+ cursor: pointer;
+ color: var(--color-text, #6b7280);
+ transition: all 0.2s;
}
-.legend-item {
- display: flex;
- align-items: center;
- gap: 8px;
+.icon-button:hover {
+ background: var(--color-spacer, #f3f4f6);
+ color: #ef4444;
}
-.legend-icon-available {
- color: #3b82f6;
+.react-flow__controls-button svg.lucide {
+ fill: none;
}
-.legend-icon-locked {
+.react-flow__controls-button {
+ transition: all 0.2s;
+ color: #000;
+}
+
+.react-flow__controls-button:hover {
+ background: #f3f4f6;
+}
+
+.react-flow__controls-button:disabled {
+ background: #f3f4f6;
color: #9ca3af;
}
-/* Drawer Styles */
-.drawer-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.7);
- z-index: 1002;
- animation: fadeIn 0.2s ease;
+/* React Flow Handles */
+.react-flow__handle {
+ width: 10px;
+ height: 10px;
+ background: #3b82f6;
+ border: 2px solid white;
}
-/* Password Input Visibility Fix */
-.drawer-password-input {
- background: none;
- color: var(--color-text);
- border: 1px solid var(--color-nav-border);
- border-radius: 6px;
- padding: 10px 36px 10px 12px;
- font-size: 16px;
+.react-flow__edge.selected {
+ outline: 1px solid #3b82f6;
+}
+
+.react-flow__node.selected {
+ outline: 2px solid #3b82f6;
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3);
+}
+
+.react-flow__node-image img {
width: 100%;
- box-sizing: border-box;
}
-/* Hide React Flow Handles */
-.react-flow__handle {
- opacity: 0 !important;
+.react-flow__node-task {
+ padding: 16px 24px;
+ border-radius: 16px;
+ border: 2px solid;
+ border-color: #3b82f6;
+ background: #f0f7ff;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+
+ .icon {
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ fill: #fff;
+ stroke: #3b82f6;
+ }
+
}
-@keyframes fadeIn {
- from {
- opacity: 0;
+.react-flow__node-topic {
+ padding: 16px 24px;
+ border-radius: 16px;
+ border: 2px solid;
+ border-color: #f59e42;
+ background: #fffbe6;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+
+ .icon {
+ position: absolute;
+ top: -10px;
+ left: -10px;
+ fill: #fff;
+ stroke: #f59e42;
}
+}
- to {
- opacity: 1;
+.react-flow__node-topic.black,
+.react-flow__node-task.black {
+ border-color: #374151;
+ background: #f3f4f6;
+
+ .icon {
+ stroke: #374151;
}
}
-@keyframes slideIn {
- from {
- transform: translateX(100%);
+.react-flow__node-topic.white,
+.react-flow__node-task.white {
+ border-color: #9ca3af;
+ background: #f9fafb;
+
+ .icon {
+ stroke: #9ca3af;
}
+}
- to {
- transform: translateX(0);
+.react-flow__node-topic.lila,
+.react-flow__node-task.lila {
+ border-color: #9e86ed;
+ background: #f3e8ff;
+
+ .icon {
+ stroke: #9e86ed;
}
}
-.drawer {
- position: fixed;
- top: 0px;
- right: 0;
- bottom: 0;
- width: 500px;
- max-width: 90vw;
- background: var(--color-nav);
- box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1);
- z-index: 1003;
- display: flex;
- flex-direction: column;
- animation: slideIn 0.3s ease;
+.react-flow__node-topic.pink,
+.react-flow__node-task.pink {
+ border-color: #ec4899;
+ background: #fdf2f8;
+
+ .icon {
+ stroke: #ec4899;
+ }
}
-.drawer iframe {
- border: none;
+.react-flow__node-topic.teal,
+.react-flow__node-task.teal {
+ border-color: #14b8a6;
+ background: #e0f2fe;
+
+ .icon {
+ stroke: #14b8a6;
+ }
}
-.drawer-header {
- padding: 24px;
- border-bottom: 1px solid var(--color-nav-border);
+.react-flow__node-topic.yellow,
+.react-flow__node-task.yellow {
+ border-color: #f59e42;
+ background: #fffbeb;
+
+ .icon {
+ stroke: #f59e42;
+ }
}
-.drawer-header h2 {
- border: none;
- margin: 0;
+.react-flow__node-topic.red,
+.react-flow__node-task.red {
+ border-color: #ef4444;
+ background: #fef2f2;
+
+ .icon {
+ stroke: #ef4444;
+ }
}
-.drawer-title {
- font-size: 24px;
- font-weight: 700;
- margin: 0 0 4px 0;
+.react-flow__node-topic.blue,
+.react-flow__node-task.blue {
+ border-color: #3b82f6;
+ background: #eff6ff;
+
+ .icon {
+ stroke: #3b82f6;
+ }
}
-.drawer-duration {
- font-size: 14px;
- color: #6b7280;
+.react-flow__node-task.completed,
+.react-flow__node-topic.completed {
+ text-decoration: line-through;
+ border-color: #10b981;
+ background: #ecfdf5;
+
+ .icon {
+ stroke: #10b981;
+ }
}
-.drawer-content {
- flex: 1;
- overflow-y: auto;
- padding: 24px;
+.react-flow__node-task.locked,
+.react-flow__node-topic.locked {
+ border-color: #6b7280;
+ background: #e5e7eb;
+
+ .icon {
+ stroke: #6b7280;
+ }
}
-.drawer-footer {
- padding: 24px;
- border-top: 1px solid var(--color-nav-border);
+.react-flow__node-task.mastered,
+.react-flow__node-topic.mastered {
+ text-decoration: line-through;
+ border-color: #10b981;
+ background: #ecfdf5;
+
+ .icon {
+ stroke: #10b981;
+ }
}
-.complete-button {
- width: 100%;
- padding: 14px;
+dialog.help[open] {
+ width: 600px;
+ max-width: 90vw;
border: none;
+ border-radius: 12px;
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
+ padding: 24px;
+ background: white;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ gap: 16px;
+ display: flex;
+ flex-direction: column;
- .legend-icon-completed {
- color: #16a34a !important;
+ table {
+ width: 100%;
+ border-collapse: collapse;
}
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- transition: all 0.2s;
-}
+ th,
+ td {
+ border: 1px solid #e5e7eb;
+ padding: 8px;
+ text-align: left;
+ }
-.complete-button:not(.locked):not(.completed) {
- background: #3b82f6;
- color: white;
+ th {
+ background: #f3f4f6;
+ }
+
+ button {
+ width: 100%;
+ }
}
-.complete-button:not(.locked):not(.completed):hover {
- background: #2563eb;
+.szh-menu__item {
+ svg {
+ margin-right: 8px;
+ }
}
-.complete-button.completed {
- background: #22c55e;
- color: white;
+.szh-menu__submenu.active,
+.szh-menu__item.active {
+ color: #3b82f6;
+
}
-.complete-button.completed:hover {
- background: #16a34a;
+.link-button {
+ color: #3b82f6;
+ text-decoration: underline;
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
}
-.complete-button.locked {
- background: #f3f4f6;
- color: #9ca3af;
- cursor: not-allowed;
+.multi-node-panel {
+ padding: 8px 24px;
+ border-bottom: 1px solid #e5e7eb;
+ background: #ffffff;
+ border-radius: 6px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ box-sizing: border-box;
+
+ button {
+ padding: 8px;
+ border: none;
+ border-radius: 6px;
+ background: white;
+ color: #1f2937;
+ font-size: 10px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ }
+
+ button:hover {
+ background: #f3f4f6;
+ border-color: #3b82f6;
+ }
}
-.react-flow__controls-button {
- color: #000 !important;
+.progress-panel {
+ width: 80%;
+ padding: 8px;
+ border-radius: 6px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ background: #fff;
+ border: 1px solid #d1d5db;
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ margin-bottom: 4px;
+
+ .mastered-counter,
+ .completed-counter {
+ color: #10b981;
+ width: 150px;
+ user-select: none;
+ }
+
+ .completed-counter {
+ display: flex;
+ justify-content: flex-start;
+ }
+
+ .mastered-counter {
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .progress-value {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ color: #266651;
+ user-select: none;
+ font-weight: 600;
+ }
+
+ .progress-bar-container {
+ position: relative;
+ width: 100%;
+ border: 1px solid #d1d5db;
+ background: #f3f4f6;
+ border-radius: 9999px;
+ overflow: hidden;
+ }
+
+ .progress-bar-fill {
+ height: 100%;
+ background: #10b981;
+ border-radius: 9999px;
+ transition: width 0.3s ease;
+ }
+
+ .progress-text {
+ font-size: 12px;
+ margin-top: 4px;
+ }
}
diff --git a/packages/web-component-learningmap/src/index.ts b/packages/web-component-learningmap/src/index.ts
index 4d670b52..0c77cd10 100644
--- a/packages/web-component-learningmap/src/index.ts
+++ b/packages/web-component-learningmap/src/index.ts
@@ -2,16 +2,14 @@ import r2wc from "@r2wc/react-to-web-component";
import { HyperbookLearningmap } from "./HyperbookLearningmap";
import "@xyflow/react/dist/style.css";
import "./index.css";
+import { HyperbookLearningmapEditor } from "./HyperbookLearningmapEditor";
-// Custom wrapper to dispatch 'change' event
const LearningmapWC = r2wc(HyperbookLearningmap, {
props: {
roadmapData: "string",
- nodeState: "json",
language: "string",
- x: "number",
- y: "number",
- zoom: "number",
+ onChange: "function",
+ initialState: "json",
},
events: {
change: true,
@@ -19,3 +17,16 @@ const LearningmapWC = r2wc(HyperbookLearningmap, {
});
customElements.define("hyperbook-learningmap", LearningmapWC);
+
+const LearningmapEditorWC = r2wc(HyperbookLearningmapEditor, {
+ props: {
+ roadmapData: "string",
+ language: "string",
+ onChange: "function",
+ },
+ events: {
+ change: true,
+ },
+});
+
+customElements.define("hyperbook-learningmap-editor", LearningmapEditorWC);
diff --git a/packages/web-component-learningmap/src/nodes/ImageNode.tsx b/packages/web-component-learningmap/src/nodes/ImageNode.tsx
new file mode 100644
index 00000000..c7bbf411
--- /dev/null
+++ b/packages/web-component-learningmap/src/nodes/ImageNode.tsx
@@ -0,0 +1,25 @@
+import { Node, NodeResizer } from "@xyflow/react";
+import { ImageNodeData } from "../types";
+
+export const ImageNode = ({ data, selected }: Node) => {
+ return (
+ <>
+ {data.data ? (
+ <>
+
+
+

+
+ >
+ ) : (
+ No Image
+ )}
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/nodes/TaskNode.tsx b/packages/web-component-learningmap/src/nodes/TaskNode.tsx
new file mode 100644
index 00000000..1960629a
--- /dev/null
+++ b/packages/web-component-learningmap/src/nodes/TaskNode.tsx
@@ -0,0 +1,44 @@
+import { Handle, Node, NodeResizer, Position } from "@xyflow/react";
+import { NodeData } from "../types";
+import { CircleCheck } from "lucide-react";
+
+export const TaskNode = ({ data, selected, isConnectable, ...props }: Node) => {
+ return (
+ <>
+ {isConnectable && }
+
+
+
+ {data.label || "Untitled"}
+
+
+ {data.summary && (
+
+ {data.summary}
+
+ )}
+
+ {["Bottom", "Top", "Left", "Right"].map((pos) => (
+
+ ))}
+
+ {["Bottom", "Top", "Left", "Right"].map((pos) => (
+
+ ))}
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/nodes/TextNode.tsx b/packages/web-component-learningmap/src/nodes/TextNode.tsx
new file mode 100644
index 00000000..0eb899c8
--- /dev/null
+++ b/packages/web-component-learningmap/src/nodes/TextNode.tsx
@@ -0,0 +1,19 @@
+import { Node } from "@xyflow/react";
+import { TextNodeData } from "../types";
+
+export const TextNode = ({ data }: Node) => {
+ return (
+ <>
+
+ {data.text || "No Text"}
+
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/nodes/TopicNode.tsx b/packages/web-component-learningmap/src/nodes/TopicNode.tsx
new file mode 100644
index 00000000..0590043a
--- /dev/null
+++ b/packages/web-component-learningmap/src/nodes/TopicNode.tsx
@@ -0,0 +1,44 @@
+import { Handle, Node, NodeResizer, Position } from "@xyflow/react";
+import { NodeData } from "../types";
+import StarCircle from "../icons/StarCircle";
+
+export const TopicNode = ({ data, selected, isConnectable }: Node) => {
+ return (
+ <>
+ {isConnectable && }
+ {data.state === "mastered" && }
+
+
+ {data.label || "Untitled"}
+
+
+ {data.summary && (
+
+ {data.summary}
+
+ )}
+
+ {["Bottom", "Top", "Left", "Right"].map((pos) => (
+
+ ))}
+
+ {["Bottom", "Top", "Left", "Right"].map((pos) => (
+
+ ))}
+ >
+ );
+};
diff --git a/packages/web-component-learningmap/src/translations.ts b/packages/web-component-learningmap/src/translations.ts
new file mode 100644
index 00000000..6e0cc81e
--- /dev/null
+++ b/packages/web-component-learningmap/src/translations.ts
@@ -0,0 +1,478 @@
+// Translations for the learningmap editor component
+
+export interface Translations {
+ // Toolbar
+ nodes: string;
+ addTask: string;
+ addTopic: string;
+ addImage: string;
+ addText: string;
+ settings: string;
+ debug: string;
+ enableDebugMode: string;
+ showCompletionNeedsEdges: string;
+ showCompletionOptionalEdges: string;
+ showUnlockAfterEdges: string;
+ preview: string;
+ save: string;
+ download: string;
+ open: string;
+ exportAsSVG: string;
+
+ // Control buttons
+ undo: string;
+ redo: string;
+ reset: string;
+ help: string;
+ unsavedChanges: string;
+
+ // Keyboard shortcuts
+ keyboardShortcuts: string;
+ action: string;
+ shortcut: string;
+ close: string;
+ shortcuts: {
+ save: string;
+ undo: string;
+ redo: string;
+ addTaskNode: string;
+ addTopicNode: string;
+ addImageNode: string;
+ addTextNode: string;
+ deleteNodeEdge: string;
+ togglePreviewMode: string;
+ toggleDebugMode: string;
+ selectMultipleNodes: string;
+ showHelp: string;
+ };
+
+ // Drawer titles
+ editTask: string;
+ editTopic: string;
+ editImage: string;
+ editText: string;
+ backgroundSettings: string;
+ editEdge: string;
+
+ // Form labels
+ nodeColor: string;
+ label: string;
+ labelRequired: string;
+ summary: string;
+ description: string;
+ duration: string;
+ videoURL: string;
+ resources: string;
+ addResource: string;
+ unlockPassword: string;
+ unlockDate: string;
+ unlockAfter: string;
+ completionNeeds: string;
+ completionOptional: string;
+ backgroundColor: string;
+ text: string;
+ fontSize: string;
+ color: string;
+ image: string;
+ width: string;
+ height: string;
+ rotation: string;
+ opacity: string;
+ edgeColor: string;
+ edgeWidth: string;
+ edgeType: string;
+ animated: string;
+
+ // Placeholders
+ placeholderNodeLabel: string;
+ placeholderShortSummary: string;
+ placeholderDetailedDescription: string;
+ placeholderVideoURL: string;
+ placeholderLabel: string;
+ placeholderURL: string;
+ placeholderOptionalPassword: string;
+ placeholderBackgroundText: string;
+ selectNode: string;
+
+ // Buttons
+ deleteNode: string;
+ saveChanges: string;
+ deleteEdge: string;
+
+ // Messages
+ openFileWarning: string;
+ failedToLoadFile: string;
+ failedToExportSVG: string;
+
+ // Color options
+ blue: string;
+ yellow: string;
+ lila: string;
+ pink: string;
+ teal: string;
+ red: string;
+ black: string;
+ white: string;
+
+ // Node defaults
+ newTask: string;
+ newTopic: string;
+ backgroundTextDefault: string;
+ noText: string;
+ untitled: string;
+
+ // Multi-node panel
+ alignLeftHorizontal: string;
+ alignCenterHorizontal: string;
+ alignRightHorizontal: string;
+ alignTopVertical: string;
+ alignCenterVertical: string;
+ alignBottomVertical: string;
+ distributeHorizontally: string;
+ distributeVertically: string;
+
+ // Edge types
+ default: string;
+ straight: string;
+ step: string;
+ smoothstep: string;
+ floating: string;
+
+ // Viewer component (LearningMap)
+ resourcesLabel: string;
+ unlockConditionsMessage: string;
+ completionNeedsMessage: string;
+ locked: string;
+ markAsStarted: string;
+ markAsCompleted: string;
+ completedLabel: string;
+ mastered: string;
+ completedTitle: string;
+ masteredTitle: string;
+
+ // Language settings
+ languageLabel: string;
+ languageEnglish: string;
+ languageGerman: string;
+}
+
+const en: Translations = {
+ // Toolbar
+ nodes: "Nodes",
+ addTask: "Add Task",
+ addTopic: "Add Topic",
+ addImage: "Add Image",
+ addText: "Add Text",
+ settings: "Settings",
+ debug: "Debug",
+ enableDebugMode: "Enable Debug Mode",
+ showCompletionNeedsEdges: "Show Completion Needs Edges",
+ showCompletionOptionalEdges: "Show Completion Optional Edges",
+ showUnlockAfterEdges: "Show Unlock After Edges",
+ preview: "Preview",
+ save: "Save",
+ download: "Download",
+ open: "Open",
+ exportAsSVG: "Export as SVG",
+
+ // Control buttons
+ undo: "Undo",
+ redo: "Redo",
+ reset: "Reset",
+ help: "Help",
+ unsavedChanges: "Unsaved Changes (Click to save or press Ctrl+S)",
+
+ // Keyboard shortcuts
+ keyboardShortcuts: "Keyboard Shortcuts",
+ action: "Action",
+ shortcut: "Shortcut",
+ close: "Close",
+ shortcuts: {
+ save: "Save",
+ undo: "Undo",
+ redo: "Redo",
+ addTaskNode: "Add Task Node",
+ addTopicNode: "Add Topic Node",
+ addImageNode: "Add Image Node",
+ addTextNode: "Add Text Node",
+ deleteNodeEdge: "Delete Node/Edge",
+ togglePreviewMode: "Toggle Preview Mode",
+ toggleDebugMode: "Toggle Debug Mode",
+ selectMultipleNodes: "Select Multiple Nodes",
+ showHelp: "Show Help",
+ },
+
+ // Drawer titles
+ editTask: "Edit Task",
+ editTopic: "Edit Topic",
+ editImage: "Edit Image",
+ editText: "Edit Text",
+ backgroundSettings: "Background Settings",
+ editEdge: "Edit Edge",
+
+ // Form labels
+ nodeColor: "Node Color",
+ label: "Label",
+ labelRequired: "Label *",
+ summary: "Summary",
+ description: "Description",
+ duration: "Duration",
+ videoURL: "Video URL",
+ resources: "Resources",
+ addResource: "Add Resource",
+ unlockPassword: "Unlock Password",
+ unlockDate: "Unlock Date",
+ unlockAfter: "Unlock After",
+ completionNeeds: "Completion Needs",
+ completionOptional: "Completion Optional",
+ backgroundColor: "Background Color",
+ text: "Text",
+ fontSize: "Font Size",
+ color: "Color",
+ image: "Image",
+ width: "Width",
+ height: "Height",
+ rotation: "Rotation",
+ opacity: "Opacity",
+ edgeColor: "Color",
+ edgeWidth: "Width",
+ edgeType: "Type",
+ animated: "Animated",
+
+ // Placeholders
+ placeholderNodeLabel: "Node label",
+ placeholderShortSummary: "Short summary",
+ placeholderDetailedDescription: "Detailed description",
+ placeholderVideoURL: "YouTube or video URL",
+ placeholderLabel: "Label",
+ placeholderURL: "URL",
+ placeholderOptionalPassword: "Optional password",
+ placeholderBackgroundText: "Background Text",
+ selectNode: "Select node...",
+
+ // Buttons
+ deleteNode: "Delete Node",
+ saveChanges: "Save Changes",
+ deleteEdge: "Delete Edge",
+
+ // Messages
+ openFileWarning: "Opening a file will replace your current map. Continue?",
+ failedToLoadFile: "Failed to load the file. Please make sure it is a valid roadmap JSON file.",
+ failedToExportSVG: "Failed to export SVG: ",
+
+ // Color options
+ blue: "Blue",
+ yellow: "Yellow",
+ lila: "Lila",
+ pink: "Pink",
+ teal: "Teal",
+ red: "Red",
+ black: "Black",
+ white: "White",
+
+ // Node defaults
+ newTask: "New task",
+ newTopic: "New topic",
+ backgroundTextDefault: "Background Text",
+ noText: "No Text",
+ untitled: "Untitled",
+
+ // Multi-node panel
+ alignLeftHorizontal: "Align Left Horizontal",
+ alignCenterHorizontal: "Align Center Horizontal",
+ alignRightHorizontal: "Align Right Horizontal",
+ alignTopVertical: "Align Top Vertical",
+ alignCenterVertical: "Align Center Vertical",
+ alignBottomVertical: "Align Bottom Vertical",
+ distributeHorizontally: "Distribute Horizontally",
+ distributeVertically: "Distribute Vertically",
+
+ // Edge types
+ default: "Default",
+ straight: "Straight",
+ step: "Step",
+ smoothstep: "Smooth Step",
+ floating: "Floating",
+
+ // Viewer component (LearningMap)
+ resourcesLabel: "Resources:",
+ unlockConditionsMessage: "Complete the following nodes first to unlock this one:",
+ completionNeedsMessage: "The following nodes need to be completed or mastered before this one is completed:",
+ locked: "Locked",
+ markAsStarted: "Mark as Started",
+ markAsCompleted: "Mark as Completed",
+ completedLabel: "Completed",
+ mastered: "Mastered",
+ completedTitle: "Completed",
+ masteredTitle: "Mastered",
+
+ // Language settings
+ languageLabel: "Language",
+ languageEnglish: "English",
+ languageGerman: "German",
+};
+
+const de: Translations = {
+ // Toolbar
+ nodes: "Knoten",
+ addTask: "Aufgabe hinzufügen",
+ addTopic: "Thema hinzufügen",
+ addImage: "Bild hinzufügen",
+ addText: "Text hinzufügen",
+ settings: "Einstellungen",
+ debug: "Debug",
+ enableDebugMode: "Debug-Modus aktivieren",
+ showCompletionNeedsEdges: "Abschluss-Benötigte Kanten anzeigen",
+ showCompletionOptionalEdges: "Abschluss-Optionale Kanten anzeigen",
+ showUnlockAfterEdges: "Entsperr-Nach Kanten anzeigen",
+ preview: "Vorschau",
+ save: "Speichern",
+ download: "Herunterladen",
+ open: "Öffnen",
+ exportAsSVG: "Als SVG exportieren",
+
+ // Control buttons
+ undo: "Rückgängig",
+ redo: "Wiederholen",
+ reset: "Zurücksetzen",
+ help: "Hilfe",
+ unsavedChanges: "Ungespeicherte Änderungen (Klicken zum Speichern oder Strg+S drücken)",
+
+ // Keyboard shortcuts
+ keyboardShortcuts: "Tastaturkürzel",
+ action: "Aktion",
+ shortcut: "Tastenkombination",
+ close: "Schließen",
+ shortcuts: {
+ save: "Speichern",
+ undo: "Rückgängig",
+ redo: "Wiederholen",
+ addTaskNode: "Aufgaben-Knoten hinzufügen",
+ addTopicNode: "Themen-Knoten hinzufügen",
+ addImageNode: "Bild-Knoten hinzufügen",
+ addTextNode: "Text-Knoten hinzufügen",
+ deleteNodeEdge: "Knoten/Kante löschen",
+ togglePreviewMode: "Vorschau-Modus umschalten",
+ toggleDebugMode: "Debug-Modus umschalten",
+ selectMultipleNodes: "Mehrere Knoten auswählen",
+ showHelp: "Hilfe anzeigen",
+ },
+
+ // Drawer titles
+ editTask: "Aufgabe bearbeiten",
+ editTopic: "Thema bearbeiten",
+ editImage: "Bild bearbeiten",
+ editText: "Text bearbeiten",
+ backgroundSettings: "Hintergrund-Einstellungen",
+ editEdge: "Kante bearbeiten",
+
+ // Form labels
+ nodeColor: "Knotenfarbe",
+ label: "Bezeichnung",
+ labelRequired: "Bezeichnung *",
+ summary: "Zusammenfassung",
+ description: "Beschreibung",
+ duration: "Dauer",
+ videoURL: "Video-URL",
+ resources: "Ressourcen",
+ addResource: "Ressource hinzufügen",
+ unlockPassword: "Entsperr-Passwort",
+ unlockDate: "Entsperr-Datum",
+ unlockAfter: "Entsperren nach",
+ completionNeeds: "Abschluss benötigt",
+ completionOptional: "Abschluss optional",
+ backgroundColor: "Hintergrundfarbe",
+ text: "Text",
+ fontSize: "Schriftgröße",
+ color: "Farbe",
+ image: "Bild",
+ width: "Breite",
+ height: "Höhe",
+ rotation: "Drehung",
+ opacity: "Deckkraft",
+ edgeColor: "Farbe",
+ edgeWidth: "Breite",
+ edgeType: "Typ",
+ animated: "Animiert",
+
+ // Placeholders
+ placeholderNodeLabel: "Knotenbezeichnung",
+ placeholderShortSummary: "Kurze Zusammenfassung",
+ placeholderDetailedDescription: "Detaillierte Beschreibung",
+ placeholderVideoURL: "YouTube oder Video-URL",
+ placeholderLabel: "Bezeichnung",
+ placeholderURL: "URL",
+ placeholderOptionalPassword: "Optionales Passwort",
+ placeholderBackgroundText: "Hintergrundtext",
+ selectNode: "Knoten auswählen...",
+
+ // Buttons
+ deleteNode: "Knoten löschen",
+ saveChanges: "Änderungen speichern",
+ deleteEdge: "Kante löschen",
+
+ // Messages
+ openFileWarning: "Das Öffnen einer Datei ersetzt Ihre aktuelle Karte. Fortfahren?",
+ failedToLoadFile: "Datei konnte nicht geladen werden. Bitte stellen Sie sicher, dass es sich um eine gültige Roadmap-JSON-Datei handelt.",
+ failedToExportSVG: "SVG-Export fehlgeschlagen: ",
+
+ // Color options
+ blue: "Blau",
+ yellow: "Gelb",
+ lila: "Lila",
+ pink: "Rosa",
+ teal: "Türkis",
+ red: "Rot",
+ black: "Schwarz",
+ white: "Weiß",
+
+ // Node defaults
+ newTask: "Neue Aufgabe",
+ newTopic: "Neues Thema",
+ backgroundTextDefault: "Hintergrundtext",
+ noText: "Kein Text",
+ untitled: "Ohne Titel",
+
+ // Multi-node panel
+ alignLeftHorizontal: "Horizontal links ausrichten",
+ alignCenterHorizontal: "Horizontal zentrieren",
+ alignRightHorizontal: "Horizontal rechts ausrichten",
+ alignTopVertical: "Vertikal oben ausrichten",
+ alignCenterVertical: "Vertikal zentrieren",
+ alignBottomVertical: "Vertikal unten ausrichten",
+ distributeHorizontally: "Horizontal verteilen",
+ distributeVertically: "Vertikal verteilen",
+
+ // Edge types
+ default: "Standard",
+ straight: "Gerade",
+ step: "Stufe",
+ smoothstep: "Weiche Stufe",
+ floating: "Schwebend",
+
+ // Viewer component (LearningMap)
+ resourcesLabel: "Ressourcen:",
+ unlockConditionsMessage: "Vervollständige zuerst die folgenden Knoten, um diesen freizuschalten:",
+ completionNeedsMessage: "Die folgenden Knoten müssen abgeschlossen oder gemeistert werden, bevor dieser abgeschlossen ist:",
+ locked: "Gesperrt",
+ markAsStarted: "Als begonnen markieren",
+ markAsCompleted: "Als abgeschlossen markieren",
+ completedLabel: "Abgeschlossen",
+ mastered: "Gemeistert",
+ completedTitle: "Abgeschlossen",
+ masteredTitle: "Gemeistert",
+
+ // Language settings
+ languageLabel: "Sprache",
+ languageEnglish: "Englisch",
+ languageGerman: "Deutsch",
+};
+
+export const translations: Record = {
+ en,
+ de,
+};
+
+export function getTranslations(language: string = "en"): Translations {
+ return translations[language] || translations.en;
+}
diff --git a/packages/web-component-learningmap/src/types.ts b/packages/web-component-learningmap/src/types.ts
new file mode 100644
index 00000000..50f2eec5
--- /dev/null
+++ b/packages/web-component-learningmap/src/types.ts
@@ -0,0 +1,98 @@
+import { Node, Edge, Box } from "@xyflow/react";
+
+// ============================================================================
+// TYPES & INTERFACES
+// ============================================================================
+
+export interface UnlockCondition {
+ after?: string[];
+ date?: string;
+ password?: string;
+}
+
+export interface CompletionNeed {
+ id: string;
+ source?: string;
+ target?: string;
+}
+
+export interface Completion {
+ needs?: CompletionNeed[];
+ optional?: CompletionNeed[];
+}
+
+export interface NodeData {
+ state: "locked" | "unlocked" | "started" | "completed" | "mastered";
+ label: string;
+ description?: string;
+ duration?: string;
+ unlock?: UnlockCondition;
+ completion?: Completion;
+ video?: string;
+ resources?: { label: string; url: string }[];
+ summary?: string;
+ [key: string]: any;
+}
+
+export interface ImageNodeData {
+ data?: string; // base64 encoded image
+}
+
+export interface TextNodeData {
+ text?: string;
+ fontSize?: number;
+ color?: string;
+ rotation?: number;
+}
+
+export type BackgroundNodeData = ImageNodeData | TextNodeData;
+
+export interface BackgroundConfig {
+ color?: string;
+ nodes?: Node[];
+}
+
+export interface Settings {
+ title?: string;
+ background?: BackgroundConfig;
+ language?: string;
+}
+
+export interface EdgeConfig {
+ animated?: boolean;
+ color?: string;
+ width?: number;
+ type?: string;
+}
+
+export interface RoadmapData {
+ nodes?: Node[];
+ edges?: Edge[];
+ settings: Settings;
+ version: number;
+}
+
+export interface RoadmapState {
+ nodes: Record;
+ x: number;
+ y: number;
+ zoom: number;
+}
+
+export type Orientation = "horizontal" | "vertical";
+
+export type HelperLine = {
+ // Used to filter out helper lines corresponding to the node being dragged
+ node: Node;
+ // We use it to check that the helper line is within the viewport.
+ nodeBox: Box;
+ // 0 for horizontal, 1 for vertical
+ orientation: Orientation;
+ // If orientation is 'horizontal', `position` holds the Y coordinate of the helper line.
+ // (Might correspond to the top or bottom position of a node, or other anchors).
+ // If orientation is 'vertical', `position` holds the X coordinate of the helper line.
+ position: number;
+ // Optional color for the helper line
+ color?: string;
+ anchorName: string;
+};
diff --git a/packages/web-component-learningmap/src/useUndoable/errors.ts b/packages/web-component-learningmap/src/useUndoable/errors.ts
new file mode 100644
index 00000000..6cb01026
--- /dev/null
+++ b/packages/web-component-learningmap/src/useUndoable/errors.ts
@@ -0,0 +1,9 @@
+export const payloadError = (func: string) => {
+ return new Error(`NoPayloadError: ${func} requires a payload.`);
+};
+
+export const invalidBehaviorError = (behavior: string) => {
+ return new Error(
+ `Mutation behavior must be one of: mergePastReversed, mergePast, keepFuture, or destroyFuture. Not: ${behavior}`,
+ );
+};
diff --git a/packages/web-component-learningmap/src/useUndoable/index.ts b/packages/web-component-learningmap/src/useUndoable/index.ts
new file mode 100644
index 00000000..228a67b1
--- /dev/null
+++ b/packages/web-component-learningmap/src/useUndoable/index.ts
@@ -0,0 +1,126 @@
+import { useReducer, useCallback } from "react";
+
+import { reducer } from "./reducer";
+
+import type {
+ Action,
+ MutationBehavior,
+ Options,
+ State,
+ UseUndoable,
+} from "./types";
+
+const initialState = {
+ past: [],
+ present: null,
+ future: [],
+};
+
+const defaultOptions: Options = {
+ behavior: "mergePastReversed",
+ historyLimit: 100,
+ ignoreIdenticalMutations: true,
+ cloneState: false,
+};
+
+const compileMutateOptions = (options: Options) => ({
+ ...defaultOptions,
+ ...options,
+});
+
+const useUndoable = (
+ initialPresent: T,
+ options: Options = defaultOptions,
+): UseUndoable => {
+ const [state, dispatch] = useReducer, [Action]>(reducer, {
+ ...initialState,
+ present: initialPresent,
+ });
+
+ const canUndo = state.past.length !== 0;
+ const canRedo = state.future.length !== 0;
+
+ const undo = useCallback(() => {
+ if (canUndo) {
+ dispatch({ type: "undo" });
+ }
+ }, [canUndo]);
+
+ const redo = useCallback(() => {
+ if (canRedo) {
+ dispatch({ type: "redo" });
+ }
+ }, [canRedo]);
+
+ const reset = useCallback(
+ (payload = initialPresent) => dispatch({ type: "reset", payload }),
+ [],
+ );
+ const resetInitialState = useCallback(
+ (payload: T) => dispatch({ type: "resetInitialState", payload }),
+ [],
+ );
+
+ const update = useCallback(
+ (payload: T, mutationBehavior: MutationBehavior, ignoreAction: boolean) =>
+ dispatch({
+ type: "update",
+ payload,
+ behavior: mutationBehavior,
+ ignoreAction,
+ ...compileMutateOptions(options),
+ }),
+ [],
+ );
+
+ // We can ignore the undefined type error here because
+ // we are setting a default value to options.
+ const setState = useCallback(
+ (
+ payload: any,
+
+ // @ts-ignore
+ mutationBehavior: MutationBehavior = options.behavior,
+ ignoreAction: boolean = false,
+ ) => {
+ return update(payload, mutationBehavior, ignoreAction);
+ },
+ [state],
+ );
+
+ // In some rare cases, the fact that the above setState
+ // function changes on every render can be problematic.
+ // Since we can't really avoid this (setState uses
+ // state.present), we must export another function that
+ // doesn't depend on the present state (and thus doesn't
+ // need to change).
+ const static_setState = (
+ payload: any,
+
+ // @ts-ignore
+ mutationBehavior: MutationBehavior = options.behavior,
+ ignoreAction: boolean = false,
+ ) => {
+ update(payload, mutationBehavior, ignoreAction);
+ };
+
+ return [
+ state.present,
+ setState,
+ {
+ past: state.past,
+ future: state.future,
+
+ undo,
+ canUndo,
+ redo,
+ canRedo,
+
+ reset,
+ resetInitialState,
+ static_setState,
+ },
+ ];
+};
+
+export default useUndoable;
diff --git a/packages/web-component-learningmap/src/useUndoable/mutate.ts b/packages/web-component-learningmap/src/useUndoable/mutate.ts
new file mode 100644
index 00000000..b5b9b857
--- /dev/null
+++ b/packages/web-component-learningmap/src/useUndoable/mutate.ts
@@ -0,0 +1,117 @@
+import { payloadError, invalidBehaviorError } from "./errors";
+
+import type { Action, State } from "./types";
+
+const ensureLimit = (limit: number | undefined, arr: any[]) => {
+ // Ensures that the `past` array doesn't exceed
+ // the specified `limit` amount. This is referred
+ // to as the `historyLimit` within the public API.
+
+ // The conditional check in the `mutate` function
+ // might pass a potentially `undefined` value,
+ // therefore we check if it's valid here.
+ if (!limit) return arr;
+
+ let n = [...arr];
+
+ if (n.length <= limit) return arr;
+
+ const exceedsBy = n.length - limit;
+
+ if (exceedsBy === 1) {
+ // This isn't faster than splice, but it works;
+ // therefore, we're leaving it.
+ // https://www.measurethat.net/Benchmarks/Show/3454/0/slice-vs-splice-vs-shift-who-is-the-fastest-to-keep-con
+ n.shift();
+ } else {
+ // This shouldn't ever happen, I think.
+ n.splice(0, exceedsBy);
+ }
+
+ return n;
+};
+
+const mutate = (state: State, action: Action): State => {
+ const { past, present, future } = state;
+ const {
+ payload,
+ behavior,
+ historyLimit,
+ ignoreIdenticalMutations,
+ cloneState,
+ ignoreAction,
+ } = action;
+
+ if (!payload || payload === undefined) {
+ // A mutation call requires a payload.
+ // I guess we _could_ simply set the state
+ // to `undefined` with an empty payload,
+ // but this would probably be considered
+ // unexpected behavior.
+ //
+ // If you want to set the state to `undefined`,
+ // pass that explicitly.
+ throw payloadError("mutate");
+ }
+
+ if (ignoreAction) {
+ return {
+ past,
+ present: payload,
+ future,
+ };
+ }
+
+ let mPast = [...past];
+
+ if (historyLimit !== "infinium" && historyLimit !== "infinity") {
+ mPast = ensureLimit(historyLimit, past);
+ }
+
+ const isEqual = JSON.stringify(payload) === JSON.stringify(present);
+
+ if (ignoreIdenticalMutations && isEqual) {
+ return cloneState ? { ...state } : state;
+ }
+
+ // We need to clone the array here because
+ // calling `future.reverse()` will mutate the
+ // existing array, causing the `mergePast` and
+ // `mergePastReversed` behaviors to work the same
+ // way.
+ const futureClone = [...future];
+
+ const behaviorMap = {
+ mergePastReversed: {
+ past: [...mPast, ...futureClone.reverse(), present],
+ present: payload,
+ future: [],
+ },
+ mergePast: {
+ past: [...mPast, ...future, present],
+ present: payload,
+ future: [],
+ },
+ destroyFuture: {
+ past: [...mPast, present],
+ present: payload,
+ future: [],
+ },
+ keepFuture: {
+ past: [...mPast, present],
+ present: payload,
+ future,
+ },
+ };
+
+ // Defaults should handle this case; mostly to make TS happy
+ if (typeof behavior === "undefined") {
+ return behaviorMap.mergePastReversed;
+ }
+
+ if (!behaviorMap.hasOwnProperty(behavior))
+ throw invalidBehaviorError(behavior);
+ return behaviorMap[behavior];
+};
+
+export { mutate };
diff --git a/packages/web-component-learningmap/src/useUndoable/reducer.ts b/packages/web-component-learningmap/src/useUndoable/reducer.ts
new file mode 100644
index 00000000..8bfeed0c
--- /dev/null
+++ b/packages/web-component-learningmap/src/useUndoable/reducer.ts
@@ -0,0 +1,88 @@
+import { mutate } from "./mutate";
+import { payloadError } from "./errors";
+
+import type { Action, State } from "./types";
+
+export const reducer = (state: State, action: Action): State => {
+ const { past, present, future } = state;
+
+ const undo = (): State => {
+ if (past.length === 0) {
+ return state;
+ }
+
+ const previous = past[past.length - 1];
+ const newPast = past.slice(0, past.length - 1);
+
+ return {
+ past: newPast,
+ present: previous,
+ future: [present, ...future],
+ };
+ };
+
+ const redo = (): State => {
+ if (future.length === 0) {
+ return state;
+ }
+
+ const next = future[0];
+ const newFuture = future.slice(1);
+
+ return {
+ past: [...past, present],
+ present: next,
+ future: newFuture,
+ };
+ };
+
+ // Transform functional updater to raw value by applying it
+ const transform = (action: Action) => {
+ action.payload =
+ typeof action.payload === "function"
+ ? action.payload(present)
+ : action.payload;
+
+ return action;
+ };
+
+ const update = (): State => mutate(state, transform(action));
+
+ const reset = (): State => {
+ const { payload } = action;
+
+ return {
+ past: [],
+ present: payload || state.present,
+ future: [],
+ };
+ };
+
+ const resetInitialState = (): State => {
+ const { payload } = action;
+
+ if (!payload) {
+ throw payloadError("resetInitialState");
+ }
+
+ // Duplicate the past for mutation
+ let mPast = [...past];
+ mPast[0] = payload;
+
+ return {
+ past: [...mPast],
+ present,
+ future: [...future],
+ };
+ };
+
+ const actions = {
+ undo,
+ redo,
+ update,
+ reset,
+ resetInitialState,
+ };
+
+ return actions[action.type]();
+};
diff --git a/packages/web-component-learningmap/src/useUndoable/types.ts b/packages/web-component-learningmap/src/useUndoable/types.ts
new file mode 100644
index 00000000..5fd95b05
--- /dev/null
+++ b/packages/web-component-learningmap/src/useUndoable/types.ts
@@ -0,0 +1,63 @@
+export type ActionType =
+ | "undo"
+ | "redo"
+ | "update"
+ | "reset"
+ | "resetInitialState";
+
+export type HistoryLimit = number | "infinium" | "infinity";
+
+export type MutationBehavior =
+ | "mergePastReversed"
+ | "mergePast"
+ | "destroyFuture"
+ | "keepFuture";
+
+export interface Action {
+ type: ActionType;
+ payload?: T;
+ behavior?: MutationBehavior;
+ historyLimit?: HistoryLimit;
+ ignoreIdenticalMutations?: boolean;
+ cloneState?: boolean;
+ ignoreAction?: boolean;
+}
+
+export interface State {
+ past: T[];
+ present: T;
+ future: T[];
+}
+
+export interface Options {
+ behavior?: MutationBehavior;
+ historyLimit?: HistoryLimit;
+ ignoreIdenticalMutations?: boolean;
+ cloneState?: boolean;
+}
+
+export type UseUndoable = [
+ T,
+ (
+ payload: T | ((oldValue: T) => T),
+ behavior?: MutationBehavior,
+ ignoreAction?: boolean,
+ ) => void,
+ {
+ past: T[];
+ future: T[];
+
+ undo: () => void;
+ canUndo: boolean;
+ redo: () => void;
+ canRedo: boolean;
+
+ reset: (initialState?: T) => void;
+ resetInitialState: (newInitialState: T) => void;
+ static_setState: (
+ payload: T,
+ behavior?: MutationBehavior,
+ ignoreAction?: boolean,
+ ) => void;
+ },
+];
diff --git a/packages/web-component-learningmap/tsconfig.json b/packages/web-component-learningmap/tsconfig.json
index 5e84980d..60e6789c 100644
--- a/packages/web-component-learningmap/tsconfig.json
+++ b/packages/web-component-learningmap/tsconfig.json
@@ -1,7 +1,10 @@
{
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
- "declaration": true,
+ "rootDir": "./src",
"jsx": "react-jsx"
- }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 994052ee..9cc4045e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -433,21 +433,21 @@ importers:
packages/web-component-learningmap:
dependencies:
- '@excalidraw/excalidraw':
- specifier: 0.18.0
- version: 0.18.0(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@r2wc/react-to-web-component':
specifier: ^2.0.4
version: 2.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@szhsin/react-menu':
+ specifier: ^4.5.0
+ version: 4.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@xyflow/react':
specifier: ^12.8.6
version: 12.8.6(@types/react@19.1.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
elkjs:
specifier: ^0.11.0
version: 0.11.0
- js-yaml:
- specifier: ^4.1.0
- version: 4.1.0
+ html-to-image:
+ specifier: 1.11.11
+ version: 1.11.11
lucide-react:
specifier: ^0.544.0
version: 0.544.0(react@19.0.0)
@@ -457,13 +457,13 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
+ tslib:
+ specifier: ^2.8.1
+ version: 2.8.1
devDependencies:
'@rollup/plugin-typescript':
specifier: ^12.1.2
version: 12.1.2(rollup@4.34.4)(tslib@2.8.1)(typescript@5.7.3)
- '@types/js-yaml':
- specifier: ^4.0.9
- version: 4.0.9
'@types/react':
specifier: ^19.0.0
version: 19.1.2
@@ -1843,6 +1843,12 @@ packages:
'@swc/types@0.1.23':
resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==}
+ '@szhsin/react-menu@4.5.0':
+ resolution: {integrity: sha512-fblZBPxFGjg+QxSbdDsWk3H8brupuQG+ayYXElwg+FdCxwLQLvrHG9K6O9+4pE8qLyDy5REn/2HmffPXcBZviA==}
+ peerDependencies:
+ react: '>=16.14.0'
+ react-dom: '>=16.14.0'
+
'@szmarczak/http-timer@5.0.1':
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
@@ -4301,6 +4307,9 @@ packages:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
+ html-to-image@1.11.11:
+ resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==}
+
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -6311,6 +6320,12 @@ packages:
'@types/react':
optional: true
+ react-transition-state@2.3.1:
+ resolution: {integrity: sha512-Z48el73x+7HUEM131dof9YpcQ5IlM4xB+pKWH/lX3FhxGfQaNTZa16zb7pWkC/y5btTZzXfCtglIJEGc57giOw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
@@ -7782,7 +7797,7 @@ snapshots:
'@azure/abort-controller@2.1.2':
dependencies:
- tslib: 2.6.2
+ tslib: 2.8.1
'@azure/core-auth@1.7.2':
dependencies:
@@ -7804,7 +7819,7 @@ snapshots:
'@azure/core-tracing': 1.1.2
'@azure/core-util': 1.9.0
'@azure/logger': 1.1.2
- tslib: 2.6.2
+ tslib: 2.8.1
transitivePeerDependencies:
- supports-color
@@ -9184,6 +9199,12 @@ snapshots:
'@swc/counter': 0.1.3
optional: true
+ '@szhsin/react-menu@4.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+ react-transition-state: 2.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+
'@szmarczak/http-timer@5.0.1':
dependencies:
defer-to-connect: 2.0.1
@@ -12143,6 +12164,8 @@ snapshots:
dependencies:
lru-cache: 6.0.0
+ html-to-image@1.11.11: {}
+
html-void-elements@3.0.0: {}
html-whitespace-sensitive-tag-names@3.0.1: {}
@@ -14544,6 +14567,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.2
+ react-transition-state@2.3.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
react@19.0.0: {}
read-pkg-up@7.0.1:
diff --git a/website/de/book/elements/learningmap.md b/website/de/book/elements/learningmap.md
index 9a211187..012420d5 100644
--- a/website/de/book/elements/learningmap.md
+++ b/website/de/book/elements/learningmap.md
@@ -5,262 +5,26 @@ permaid: lernkarte
# Lernkarte
-Das `learningmap`-Element ermöglicht es dir, interaktive Lernfahrpläne direkt in deine Markdown-Dateien einzubetten. Du musst **kein** HTML schreiben.
+Das `learningmap`-Element ermöglicht es dir, interaktive Lernpfade direkt in deine Markdown-Dateien einzubetten. Du musst dafür **kein** HTML schreiben.
## Grundlegende Verwendung
Um eine Lernkarte hinzuzufügen, verwende folgenden Markdown-Block:
````markdown
-:::learningmap
-
-```yaml
-title: Moderne Webentwicklung Fahrplan
-background:
- color: '#f8fafc'
- image:
- src: 'learningmap.svg'
- x: 0
- y: 0
-edges:
- animated: false
- color: '#94a3b8'
- width: 2
- type: bezier
-nodes:
- - id: '1'
- type: topic
- data:
- label: Einführung in HTML
- description: |
- Verstehe die Struktur und Semantik von HTML-Dokumenten.
- duration: 1 Stunde
- unlock: {}
- completion:
- needs:
- - id: "2"
- source: bottom
- target: top
- optional:
- - id: "3"
- source: bottom
- target: top
- video: https://youtube.com/watch?v=UB1O30fR-EE
- resources:
- - label: MDN HTML Einführung
- url: https://developer.mozilla.org/en-US/docs/Web/HTML
- - label: HTML Grundlagen Tutorial
- url: https://www.w3schools.com/html/
- - id: '2'
- type: task
- data:
- label: Schreibe deine erste HTML-Datei
- description: Erstelle eine einfache HTML-Seite.
- duration: 1 Stunde
- resources:
- - label: HTML Seiten Anleitung
- url: https://www.freecodecamp.org/news/how-to-build-your-first-web-page/
- - id: '3'
- type: task
- data:
- label: Füge eine Überschrift und einen Absatz hinzu
- description: Füge grundlegende Elemente zu deiner HTML-Seite hinzu.
- duration: 1 Stunde
- unlock:
- after:
- - "2"
- resources:
- - label: HTML Elemente
- url: https://developer.mozilla.org/en-US/docs/Web/HTML/Element
-```
-:::
-````
-
-:::learningmap
-
-```yaml
-title: Moderne Webentwicklung Fahrplan
-background:
- color: '#f8fafc'
- image:
- src: 'learningmap.svg'
- x: 0
- y: 0
-edges:
- animated: false
- color: '#94a3b8'
- width: 2
- type: bezier
-nodes:
- - id: '1'
- type: topic
- data:
- label: Einführung in HTML
- description: |
- Verstehe die Struktur und Semantik von HTML-Dokumenten.
- duration: 1 Stunde
- unlock: {}
- completion:
- needs:
- - id: "2"
- source: bottom
- target: top
- optional:
- - id: "3"
- source: bottom
- target: top
- video: https://youtube.com/watch?v=UB1O30fR-EE
- resources:
- - label: MDN HTML Einführung
- url: https://developer.mozilla.org/en-US/docs/Web/HTML
- - label: HTML Grundlagen Tutorial
- url: https://www.w3schools.com/html/
- - id: '2'
- type: task
- data:
- label: Schreibe deine erste HTML-Datei
- description: Erstelle eine einfache HTML-Seite.
- duration: 1 Stunde
- resources:
- - label: HTML Seiten Anleitung
- url: https://www.freecodecamp.org/news/how-to-build-your-first-web-page/
- - id: '3'
- type: task
- data:
- label: Füge eine Überschrift und einen Absatz hinzu
- description: Füge grundlegende Elemente zu deiner HTML-Seite hinzu.
- duration: 1 Stunde
- unlock:
- after:
- - "2"
- resources:
- - label: HTML Elemente
- url: https://developer.mozilla.org/en-US/docs/Web/HTML/Element
-```
-:::
-
-## Höhe einstellen
-
-Du kannst die Höhe der Lernkarte festlegen, indem du ein `height`-Attribut in geschweiften Klammern nach `learningmap` angibst:
-
-````markdown
-:::learningmap{height="600px"}
-
-```yaml
-# roadmap data here
-```
-:::
+::learningmap{id="learningmap-example" height="600px" src="test.learningmap.json"}
````
-- Wenn du das `height`-Attribut **nicht** setzt, verwendet das Element standardmäßig die volle Höhe des Viewports.
-
-## Knoten mit Kanten verbinden
-
-- Knoten werden automatisch mit Kanten verbunden, basierend auf der `completion`-Eigenschaft von Themenknoten.
-- Die Needs-Liste in der `completion`-Eigenschaft definiert, welche Knoten abgeschlossen sein müssen, bevor das Thema als abgeschlossen gilt.
-- Du kannst die Position der Kanten mit den Eigenschaften `source` und `target` (oben, unten, links, rechts) anpassen.
-
-## Automatisches Knoten-Layout
-
-- Wenn ein Knoten **keine** `position` angibt (kein `x` und `y`), wird er automatisch vom Layout-Algorithmus platziert.
-- So bleibt dein Fahrplan organisiert, auch wenn du manuelle Positionen weglässt.
-
-## Knotentypen: Thema und Aufgabe
-
-Das Lernkarten-Element unterstützt zwei Knotentypen:
-
-- **Themenknoten** (`type: topic`)
-- **Aufgabenknoten** (`type: task`)
-
-**Aufgabenknoten**
-- Stellen einzelne Aktivitäten oder Aufgaben dar.
-- Sollten **keine** `completion`-Eigenschaft haben.
-- Werden direkt vom Benutzer abgeschlossen.
-
-**Themenknoten**
-- Stellen größere Themen oder Module dar.
-- Sollten eine `completion`-Eigenschaft enthalten.
-- Ein Themenknoten gilt als abgeschlossen, wenn alle zugehörigen Aufgaben oder Unterthemen abgeschlossen sind.
-- Die `completion`-Eigenschaft listet die erforderlichen Knoten (nach `id`) auf, die abgeschlossen sein müssen, damit das Thema als abgeschlossen gilt. Füge `target` und `source` hinzu, um die Richtung der Kantenverbindungen anzupassen. Du kannst `bottom`, `top`, `left` oder `right` setzen.
-
-**Beispiel:**
-
-````yaml
-nodes:
- - id: '1'
- type: topic
- position:
- x: 0
- y: 0
- data:
- label: "HTML lernen"
- completion:
- needs:
- - id: "2"
- - id: "3"
- - id: '2'
- type: task
- position:
- x: -150
- y: 150
- data:
- label: "Schreibe deine erste HTML-Datei"
- - id: '3'
- type: task
- data:
- label: "Füge eine Überschrift und einen Absatz hinzu"
-````
-
-In diesem Beispiel wird das Thema "HTML lernen" erst als abgeschlossen markiert, wenn beide Aufgaben ("Schreibe deine erste HTML-Datei" und "Füge eine Überschrift und einen Absatz hinzu") abgeschlossen sind.
-
-## Kanten-Anpassung
-
-Du kannst das Aussehen der Kanten, die Knoten verbinden, mit der `edges`-Eigenschaft anpassen:
-
-````yaml
-edges:
- animated: true
- color: '#ff0000'
- width: 3
- type: bezier
-````
-
-- `animated`: Setze auf `true`, um animierte Kanten zu aktivieren. Dadurch werden alle Kanten gestrichelt und animiert.
-- `color`: Definiere die Farbe der Kanten mit einem Hex-Code.
-- `width`: Lege die Breite der Kanten in Pixeln fest.
-- `type`: Wähle den Typ der Kante. Optionen sind `bezier` oder `smoothstep`.
-
-## Freischaltbedingungen
-
-Knoten können basierend auf folgenden Bedingungen gesperrt oder freigeschaltet werden:
-
-- Abschluss anderer Knoten (`after`)
-- Bestimmte Daten (`date`)
-- Passwörter (`password`)
-
-**Beispiel:**
-
-````yaml
-unlock:
- after:
- - "1"
- date: "2025-10-01"
- password: "webdev2025"
-````
-
-## Fortschrittsanzeige
-
-- Benutzer können Knoten als gestartet oder abgeschlossen markieren.
-- Der Fortschritt wird oben auf der Karte angezeigt.
-- Der Fortschritt wird in deinem Browser gespeichert. Falls eine ID angegeben wird, wird diese als Schlüssel verwendet. Wenn keine ID angegeben wird, wird der Inhalt gehasht und eine ID generiert.
+::learningmap{id="learningmap-example" height="600px" src="test.learningmap.json"}
-## Ressourcen und Videos
+## Attribute
-- Jeder Knoten kann Ressourcen und einen Videolink für weiteres Lernen enthalten.
+- `id` (erforderlich): Eine eindeutige Kennung für die Lernkarten-Instanz.
+- `height` (optional): Die Höhe des Lernkarten-Containers (z.B. `600px`, `100%`).
+- `src` (erforderlich): Der Pfad zur JSON-Datei, die die Struktur der Lernkarte definiert.
-## Debug-Knoten
+## Editor
-Du kannst einen Debug-Knoten anzeigen, indem du beim Betrachten der Lernkarte **Strg + Leertaste** drückst.
+Du solltest den Learningmap-Editor verwenden, um deine Lernkarten zu erstellen und zu verwalten. Der Editor bietet eine benutzerfreundliche Oberfläche, um Lernpfade zu gestalten und sie als JSON-Dateien zu exportieren.
-- Der Debug-Knoten zeigt die Position (`x`, `y`), Breite und Höhe aller Knoten auf der Karte.
-- Dies ist besonders nützlich, wenn du ein benutzerdefiniertes Hintergrundbild erstellen möchtest, das zum Layout deiner Lernkarte passt.
+[Learningmap Editor öffnen](https://learningmap.openpatch.org/editor)
diff --git a/website/de/book/elements/learningmap.svg b/website/de/book/elements/learningmap.svg
deleted file mode 100644
index d8dba9a9..00000000
--- a/website/de/book/elements/learningmap.svg
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
diff --git a/website/de/book/elements/test.learningmap.json b/website/de/book/elements/test.learningmap.json
new file mode 100644
index 00000000..ce041615
--- /dev/null
+++ b/website/de/book/elements/test.learningmap.json
@@ -0,0 +1,79 @@
+{
+ "nodes": [
+ {
+ "id": "node1",
+ "type": "task",
+ "position": {
+ "x": 135,
+ "y": 105
+ },
+ "data": {
+ "label": "Getting Started",
+ "summary": "Introduction to the topic",
+ "description": "Learn the fundamentals",
+ "resources": [
+ {
+ "label": "Documentation",
+ "url": "https://example.com/docs"
+ }
+ ]
+ }
+ },
+ {
+ "id": "node2",
+ "type": "topic",
+ "position": {
+ "x": 100,
+ "y": 300
+ },
+ "data": {
+ "label": "Advanced Topics",
+ "summary": "Deep dive into advanced concepts",
+ "unlock": {
+ "after": [
+ "node1"
+ ]
+ },
+ "completion": {
+ "needs": [
+ "node1"
+ ]
+ }
+ }
+ },
+ {
+ "id": "background-node3",
+ "type": "image",
+ "position": {
+ "x": -180,
+ "y": -45
+ },
+ "data": {
+ "src": "",
+ "data": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgaWQ9InN2ZzEyMDgxIgogICB2ZXJzaW9uPSIxLjEiCiAgIHZpZXdCb3g9IjAgMCAxMjUuOTQ5MDIgMTI1Ljk0ODYxIgogICBoZWlnaHQ9IjEyNS45NDg2MW1tIgogICB3aWR0aD0iMTI1Ljk0OTAybW0iCiAgIHNvZGlwb2RpOmRvY25hbWU9Im9wZW5wYXRjaC1jaXJjbGUtd2hpdGUuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjQgKGU3YzNmZWIxMDAsIDIwMjQtMTAtMDkpIgogICBpbmtzY2FwZTpleHBvcnQtZmlsZW5hbWU9Ii9ob21lL21iYXJrbWluL0Nsb3VkL1Byb2pla3RlL29wZW5wYXRjaC9sb2dvX25ldWVfZmFyYmVuX2NpcmNsZS5wbmciCiAgIGlua3NjYXBlOmV4cG9ydC14ZHBpPSIxNTcuNzQ0ODYiCiAgIGlua3NjYXBlOmV4cG9ydC15ZHBpPSIxNTcuNzQ0ODYiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMSIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGdyaWR0b2xlcmFuY2U9IjEwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMTMxIgogICAgIGlkPSJuYW1lZHZpZXcyNCIKICAgICBzaG93Z3JpZD0iZmFsc2UiCiAgICAgZml0LW1hcmdpbi10b3A9IjAiCiAgICAgZml0LW1hcmdpbi1sZWZ0PSIwIgogICAgIGZpdC1tYXJnaW4tcmlnaHQ9IjAiCiAgICAgZml0LW1hcmdpbi1ib3R0b209IjAiCiAgICAgaW5rc2NhcGU6em9vbT0iMC45NTA0NTk2NyIKICAgICBpbmtzY2FwZTpjeD0iMTY0LjEzMTExIgogICAgIGlua3NjYXBlOmN5PSIyNjkuMzQzMzYiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJnOTEyLTg5IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXJvdGF0aW9uPSIwIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjEiCiAgICAgaW5rc2NhcGU6ZG9jdW1lbnQtdW5pdHM9Im1tIgogICAgIGlua3NjYXBlOnNob3dwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIgLz4KICA8ZGVmcwogICAgIGlkPSJkZWZzMTIwNzUiPgogICAgPGNsaXBQYXRoCiAgICAgICBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGlkPSJjbGlwUGF0aDE4LTktNiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCw0MDAgSCA1NTAgViAwIEggMCBaIgogICAgICAgICBpZD0icGF0aDE2LTktNyIKICAgICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEyMDc4Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk3Ljc2NTUwOCw0Ni4zMzQ3MDgpIgogICAgIGlkPSJsYXllcjEiPgogICAgPGcKICAgICAgIGlkPSJnMTEyMiIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuMzUyNzc3NzcsMCwwLC0wLjM1Mjc3Nzc3LC0xNi43OTk1ODEsMjU2LjYwMjU0KSI+CiAgICAgIDxnCiAgICAgICAgIGlkPSJnOTEyLTg5IgogICAgICAgICB0cmFuc2Zvcm09InJvdGF0ZSgtNDUsNDkzLjA0MTQ3LDQxNi4xNDU2NykiPgogICAgICAgIDxlbGxpcHNlCiAgICAgICAgICAgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLW9wYWNpdHk6MTtzdHJva2U6I2ZmZmZmZjtzdHJva2Utd2lkdGg6OS44ODkxMztzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgICBpZD0icGF0aDg3MiIKICAgICAgICAgICBjeD0iOTguODUxNjMxIgogICAgICAgICAgIGN5PSItMjA5LjY5MDcyIgogICAgICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuNzA3MTA2NzgsMC43MDcxMDY3OCwwLjcwNzEwNjc4LC0wLjcwNzEwNjc4LDAsMCkiCiAgICAgICAgICAgcng9IjEyMy4wNjM4MiIKICAgICAgICAgICByeT0iMTIzLjA2MzQiIC8+CiAgICAgICAgPHBhdGgKICAgICAgICAgICBpZD0icmVjdDY0LTMtMCIKICAgICAgICAgICBkPSJtIC0xODkuNDk1NjksMjIwLjA3MDcxIDEwOS4yNzE3MDgsMTA5LjI3MTcxIGMgMS4xNDU4MDUsMS4xNDU4IDIuOTkwNjY5LDEuMTQ1OCA0LjEzNjQ3MywtMWUtNSBMIDMzLjE4NDIwMiwyMjAuMDcwNyBjIDEuMTQ1ODAxLC0xLjE0NTggMS4xNDU4MDUsLTIuOTkwNjYgMCwtNC4xMzY0NyBMIC03Ni4wODc1MDgsMTA2LjY2MjUyIGMgLTEuMTQ1ODAyLC0xLjE0NTggLTIuOTkwNjY4LC0xLjE0NTggLTQuMTM2NDY5LDAgTCAtMTg5LjQ5NTY5LDIxNS45MzQyMyBjIC0xLjE0NTgsMS4xNDU4MSAtMS4xNDU4LDIuOTkwNjcgMCw0LjEzNjQ4IHoiCiAgICAgICAgICAgc3R5bGU9Im9wYWNpdHk6MTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOiNmZmZmZmY7c3Ryb2tlLXdpZHRoOjcuMTk2NTk7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgICAgICA8cGF0aAogICAgICAgICAgIGlkPSJwYXRoMjItNy0wIgogICAgICAgICAgIHN0eWxlPSJvcGFjaXR5OjE7ZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lO3N0cm9rZS13aWR0aDowLjcxNzA5MiIKICAgICAgICAgICBkPSJtIC03OS41NTQwNjMsMTgxLjEzMDYxIGMgMC42NjkwNDcsMC4wNDggMS4zMzg4MTEsLTAuMjYzMTggMi4wMDc4NTgsLTAuOTMyMjIgbCA2LjA1OTQyNiwtNi4wNTk0MyBjIDAuMDk1MzcsLTAuMDk1NCAwLjE5MTQ2NCwtMC4xOTE0NiAwLjI4NjgzNywtMC4yODY4NCBsIDcuNTI5NDY1LC03LjUyOTQ2IGMgMS4yNDI3MiwtMS4yNDI3MiAxLjI0MjcyLC0yLjQ5NzYzIDAsLTMuNzY0NzMgbCAtMTEuODMyMDE2LC0xMS43OTYxNyBjIC0xLjI0MjcyMSwtMS4yNDI3MiAtMi40ODYxNTgsLTEuMjQyNzIgLTMuNzI4ODc4LDAgbCAtNy41Mjk0NjUsNy41Mjk0NyBjIC0wLjA5NTM3LDAuMDk1NCAtMC4xNjcwODMsMC4yMTUxMyAtMC4yMTUxMjgsMC4zNTg1NCBsIC0wLjA3MTcxLDAuMDM1OSAtMC4wNzE3MSwtMC4wMzU5IC01Ljk4NzcxOCw1Ljk4NzcyIGMgLTEuMjY3MTAxLDEuMjQyNzIgLTEuMjY3MTAxLDIuNDg2MTYgMCwzLjcyODg4IGwgMTEuNzk2MTYyLDExLjgzMjAyIGMgMC41OTczMzgsMC41NzM2NyAxLjE4MzIwMiwwLjg4NDE3IDEuNzU2ODc1LDAuOTMyMjIgbSAxMS4yMjI0ODksOTYuMTI2MTcgYyAxLjI5MDc2NSwtMS4yNDI3MiAxLjMxNDQyOSwtMi41MDk4MyAwLjA3MTcxLC0zLjgwMDU5IGwgLTExLjgzMjAxNywtMTEuNzYwMzEgYyAtMS4yOTA3NjUsLTEuMjkwNzYgLTIuNTgxNTMsLTEuMzE0NDMgLTMuODcyMjk2LC0wLjA3MTcgbCAtNy41NjUzMTksNy41Mjk0NyBjIC0xLjI0MjcyMSwxLjI5MDc2IC0xLjIxOTA1NywyLjU4MTUzIDAuMDcxNzEsMy44NzIyOSBsIDExLjkwMzcyNSwxMS44MzIwMiBjIDEuMjQyNzIxLDEuMTk1MzkgMi40NjE3NzcsMS4xNzEwMSAzLjY1NzE2OSwtMC4wNzE3IGwgNy41NjUzMiwtNy41Mjk0NiBtIC0zMy4zNDQ3NzYsLTMzLjM0NDc4IC03LjUyOTQ2LDcuNTY1MzIgYyAtMS4yNDI3MiwxLjI5MDc3IC0xLjIxOTA2LDIuNTgxNTMgMC4wNzE3LDMuODcyMyBsIDExLjcyNDQ0OSwxMS43NjAzIGMgMS4yOTA3NjUsMS4yOTA3NyAyLjU2OTM0LDEuMjkwNzcgMy44MzY0NDIsMCBsIDcuNTI5NDY1LC03LjU2NTMxIGMgMS4yOTA3NjUsLTEuMjQyNzIgMS4yOTA3NjUsLTIuNTA5ODMgMCwtMy44MDA1OSBsIC0xMS43NjAzMDgsLTExLjc2MDMxIGMgLTEuMjkwNzY1LC0xLjI5MDc2IC0yLjU4MTUyOCwtMS4zMTQ0MyAtMy44NzIyOTgsLTAuMDcxNyBtIC0yLjExNTQyLC0yLjExNTQyIGMgMS4yOTA3NywtMS4yNjcxIDEuMzAyOTYsLTIuNTIyMDEgMC4wMzU5LC0zLjc2NDczIGwgLTExLjc5NjE3LC0xMS43OTYxNiBjIC0xLjI0MjcyLC0xLjI2NzExIC0yLjUwOTgyLC0xLjI2NzExIC0zLjgwMDU4LDAgbCAtNy41NjUzMiw3LjUyOTQ2IGMgLTEuMjQyNzIsMS4yOTA3NyAtMS4yNDI3MiwyLjU1Nzg3IDAsMy44MDA1OSBsIDExLjgzMjAxLDExLjgzMjAxIGMgMS4yNDI3MiwxLjI0MjczIDIuNDg2MTYsMS4yMTkwNiAzLjcyODg4LC0wLjA3MTcgbCA3LjU2NTMyLC03LjUyOTQ3IG0gLTQwLjgzODM4LC0yMS45MDcxNiAxMS44MzIwMSwxMS43OTYxNiBjIDEuMjQyNzIsMS4yNjcxMSAyLjQ4NjE2LDEuMjQyNzMgMy43Mjg4OCwtMC4wNzE3IGwgNy41NjUzMiwtNy41Mjk0NyBjIDEuMjkwNzcsLTEuMjQyNzIgMS4zMTQ0MywtMi40ODYxNiAwLjA3MTcsLTMuNzI4ODggbCAtMTEuODMyMDIsLTExLjgzMjAxIGMgLTEuMjQyNzIsLTEuMjQyNzIgLTIuNTA5ODIsLTEuMjQyNzIgLTMuODAwNTgsMCBsIC03LjU2NTMyLDcuNTI5NDYgYyAtMS4yNDI3MiwxLjI5MDc3IC0xLjI0MjcyLDIuNTY5MzQgMCwzLjgzNjQ0IG0gMTA3LjI3Njk0NiwyNi40MjQ4NCA3LjU2NTMyLC03LjUyOTQ3IGMgMS4yNDI3MiwtMS4yNDI3MiAxLjI0MjcyLC0yLjUwOTgyIDAsLTMuODAwNTggbCAtNjUuMDA0MzgyLC02NC45MzI2NyBjIC0xLjI0MjcyLC0xLjI0MjcyIC0yLjQ4NjE1NywtMS4yNDI3MiAtMy43Mjg4NzcsMCBsIC03LjU2NTMxNyw3LjU2NTMyIGMgLTAuMTE5NzYsMC4xMTk3NSAtMC4yMDI5NCwwLjI1MDk4IC0wLjI1MDk5LDAuMzk0NCAtMC4zMTA1LDAuMjE1MTIgLTAuNTk3MzMsMC40MzAyNSAtMC44NjA1MSwwLjY0NTM4IGwgLTE1Ljc3NjAyLDE1Ljg0NzczIGMgLTAuMjE1MTIsMC4yMTUxMyAtMC40MTgwNiwwLjQ1MzkyIC0wLjYwOTUzLDAuNzE3MDkgLTAuMTY3MDgsMC4wOTU0IC0wLjMyMjY5LDAuMjE1MTMgLTAuNDY2MTEsMC4zNTg1NSBsIC03LjUyOTQ2LDcuNTI5NDYgYyAtMS4yOTA3NywxLjI0MjcyIC0xLjMxNDQzLDIuNDg2MTYgLTAuMDcxNywzLjcyODg4IGwgMTEuODMyMDIsMTEuODMyMDIgYyAxLjI0MjcyLDEuMjQyNzIgMi41MDk4MiwxLjI0MjcyIDMuODAwNTgsMCBsIDcuNTI5NDcsLTcuNTI5NDcgYyAwLjE0MzQyLC0wLjE0MzQyIDAuMjc0NjUsLTAuMjg2ODMgMC4zOTQ0LC0wLjQzMDI1IDAuMjM4NzksLTAuMjE1MTMgMC40NjYxMSwtMC40MzAyNiAwLjY4MTI0LC0wLjY0NTM5IGwgMTQuOTUxMzYzLC0xNS4wMjMwNyAzNy41Mzk3NjEsMzcuNTM5NzYgLTE0LjE2MjU2NSwxNC4xOTg0MiAtMzMuNTI0MDQ5LC0zMy41MjQwNSBjIC0xLjI0MjcyLC0xLjI0MjcyIC0yLjQ4NjE1LC0xLjI0MjcyIC0zLjcyODg4LDAgbCAtNy41NjUzMSw3LjU2NTMyIGMgLTEuMjkwNzcsMS4yNDI3MiAtMS4zMTQ0MywyLjQ4NjE2IC0wLjA3MTcsMy43Mjg4OCBsIDQ3LjI5MjIwNyw0Ny4yMjA1IGMgMS4yNDI3MiwxLjI5MDc3IDIuNTA5ODIyLDEuMzE0NDMgMy44MDA1ODcsMC4wNzE3IGwgNy41NjUzMiwtNy41NjUzMiBjIDAuMDk1MzcsLTAuMDcxNyAwLjE3OTI3MywtMC4xNTU2MSAwLjI1MDk4MiwtMC4yNTA5OCBsIDAuNTM3ODE5LC0wLjUzNzgyIDE2LjM4NTU1LC0xNi4zNDk2OSBjIDAuMTY3MDgyLC0wLjE5MTQ3IDAuMzQ2MzU1LC0wLjM3MDc0IDAuNTM3ODE5LC0wLjUzNzgyIDAuMDIzNjYsLTAuMDk1NCAwLjEwNzU2NCwtMC4xOTE0NyAwLjI1MDk4MiwtMC4yODY4NCBNIDYuMTM4NDIsMjE2LjI2ODExIGMgMS4xNDczNDcxLDEuMTQ3MzUgMS4xNDczNDcxLDIuMzA2ODggMCwzLjQ3Nzg5IGwgLTgyLjU3MzEzMyw4Mi41MzcyOCBjIC0xLjAyNzU5MiwxLjAyNzYgLTIuMDY3Mzc2LDEuMTIzNjkgLTMuMTE5MzUsMC4yODY4NCAtMC4wOTUzNywtMC4wOTU0IC0wLjIxNTEyNywtMC4xOTE0NiAtMC4zNTg1NDYsLTAuMjg2ODQgbCAtODIuNTM3MjgxLC04Mi40NjU1NyBjIC0xLjE0NzM0LC0xLjIxOTA1IC0xLjE0NzM0LC0yLjQwMjI1IDAsLTMuNTQ5NiBsIDgyLjUzNzI4MSwtODIuNTM3MjggMC4zNTg1NDYsLTAuMzU4NTUgYyAxLjA1MTk3NCwtMC43ODg4IDIuMDkxNzU4LC0wLjY0NTM4IDMuMTE5MzUsMC40MzAyNiBMIDYuMTM4NDIsMjE2LjI2ODExIgogICAgICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICAgIDwvZz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo="
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "xy-edge__node2top-node1bottom",
+ "source": "node2",
+ "target": "node1",
+ "sourceHandle": "top",
+ "targetHandle": "bottom",
+ "animated": false,
+ "type": "default",
+ "style": {
+ "stroke": "#94a3b8",
+ "strokeWidth": 2
+ }
+ }
+ ],
+ "settings": {
+ "title": "Sample Learning Roadmap",
+ "background": {
+ "color": "#10844e"
+ }
+ },
+ "version": 1
+}
\ No newline at end of file
diff --git a/website/en/book/changelog.md b/website/en/book/changelog.md
index b975bd7d..f37c4a4f 100644
--- a/website/en/book/changelog.md
+++ b/website/en/book/changelog.md
@@ -38,6 +38,18 @@ If you need a new feature, open an [issue](https://github.com/openpatch/hyperboo
::::
-->
+## v0.59.0
+
+::::tabs
+
+:::tab{title="New :rocket:" id="new"}
+
+- Vastly improved learningmap element for displaying interactive learning maps. [Learn more](/elements/learningmap)
+
+:::
+
+::::
+
## v0.58.2
::::tabs
diff --git a/website/en/book/elements/learningmap.md b/website/en/book/elements/learningmap.md
index f3dedcf7..2301e43f 100644
--- a/website/en/book/elements/learningmap.md
+++ b/website/en/book/elements/learningmap.md
@@ -12,257 +12,19 @@ The `learningmap` element lets you embed interactive learning roadmaps directly
To add a learning map, use the following Markdown block:
````markdown
-:::learningmap{id="learningmap-example"}
-
-```yaml
-title: Modern Web Development Roadmap
-background:
- color: '#f8fafc'
- image:
- src: 'learningmap.svg'
- x: 0
- y: 0
-edges:
- animated: false
- color: '#94a3b8'
- width: 2
- type: bezier
-nodes:
- - id: '1'
- type: topic
- data:
- label: Introduction to HTML
- description: |
- Understand the structure and semantics of HTML documents.
- duration: 1 hour
- unlock: {}
- completion:
- needs:
- - id: "2"
- source: bottom
- target: top
- optional:
- - id: "3"
- source: bottom
- target: top
- video: https://youtube.com/watch?v=UB1O30fR-EE
- resources:
- - label: MDN HTML Introduction
- url: https://developer.mozilla.org/en-US/docs/Web/HTML
- - label: HTML Basics Tutorial
- url: https://www.w3schools.com/html/
- - id: '2'
- type: task
- data:
- label: Write your first HTML file
- description: Create a simple HTML page.
- duration: 1 hour
- resources:
- - label: HTML Page Guide
- url: https://www.freecodecamp.org/news/how-to-build-your-first-web-page/
- - id: '3'
- type: task
- data:
- label: Add a heading and a paragraph
- description: Add basic elements to your HTML page.
- duration: 1 hour
- unlock:
- after:
- - "2"
- resources:
- - label: HTML Elements
- url: https://developer.mozilla.org/en-US/docs/Web/HTML/Element
-```
-:::
-````
-
-:::learningmap{id="learningmap-example"}
-
-```yaml
-title: Modern Web Development Roadmap
-background:
- color: '#f8fafc'
- image:
- src: 'learningmap.svg'
- x: 0
- y: 0
-edges:
- animated: false
- color: '#94a3b8'
- width: 2
- type: bezier
-nodes:
- - id: '1'
- type: topic
- data:
- label: Introduction to HTML
- description: |
- Understand the structure and semantics of HTML documents.
- duration: 1 hour
- unlock: {}
- completion:
- needs:
- - id: "2"
- source: bottom
- target: top
- optional:
- - id: "3"
- source: bottom
- target: top
- video: https://youtube.com/watch?v=UB1O30fR-EE
- resources:
- - label: MDN HTML Introduction
- url: https://developer.mozilla.org/en-US/docs/Web/HTML
- - label: HTML Basics Tutorial
- url: https://www.w3schools.com/html/
- - id: '2'
- type: task
- data:
- label: Write your first HTML file
- description: Create a simple HTML page.
- duration: 1 hour
- resources:
- - label: HTML Page Guide
- url: https://www.freecodecamp.org/news/how-to-build-your-first-web-page/
- - id: '3'
- type: task
- data:
- label: Add a heading and a paragraph
- description: Add basic elements to your HTML page.
- duration: 1 hour
- unlock:
- after:
- - "2"
- resources:
- - label: HTML Elements
- url: https://developer.mozilla.org/en-US/docs/Web/HTML/Element
-```
-:::
-
-## Setting the Height
-
-You can set the height of the learning map by passing a `height` attribute in curly braces after `learningmap`:
-
-````markdown
-:::learningmap{height="600px"}
-
-```yaml
-# roadmap data here
-```
-:::
+::learningmap{id="learningmap-example" height="600px" src="test.learningmap.json"}
````
-- If you do **not** set the `height` attribute, the element will use the full viewport height by default.
-
-## Connecting Nodes with Edges
-
-- Nodes are automatically connected with edges based on the `completion` property of topic nodes.
-- The needs list in the `completion` property defines which nodes must be completed before the topic is considered complete.
-- You can customize the position of the edges using `source` and `target` properties (top, bottom, left, right).
-
-## Automatic Node Layout
-
-- If a node does **not** specify a `position` (no `x` and `y`), it will be placed automatically by the layout algorithm.
-- This keeps your roadmap organized even if you omit manual positions.
-
-## Node Types: Topic and Task
-
-The learningmap element supports two types of nodes:
-
-- **Topic Nodes** (`type: topic`)
-- **Task Nodes** (`type: task`)
-
-**Task Nodes**
-- Represent individual activities or assignments.
-- Should **not** have a `completion` property.
-- Are completed directly by the user.
-
-**Topic Nodes**
-- Represent broader subjects or modules.
-- Should include a `completion` property.
-- A topic node is considered completed when all its related tasks or subtopics are completed.
-- The `completion` property lists the required nodes (by `id`) that must be completed for the topic to be marked as complete. Add `target` and `source` to customize the direction of edge connections. You can set `bottom`, `top`, `left`, or `right`.
-
-**Example:**
-
-````yaml
-nodes:
- - id: '1'
- type: topic
- position:
- x: 0
- y: 0
- data:
- label: "Learn HTML"
- completion:
- needs:
- - id: "2"
- - id: "3"
- - id: '2'
- type: task
- position:
- x: -150
- y: 150
- data:
- label: "Write your first HTML file"
- - id: '3'
- type: task
- data:
- label: "Add a heading and a paragraph"
-````
-
-In this example, the topic "Learn HTML" will be marked as completed only when both tasks ("Write your first HTML file" and "Add a heading and a paragraph") are completed.
-
-## Edge Customization
-
-You can customize the appearance of edges connecting nodes using the `edges` property:
-
-````yaml
-edges:
- animated: true
- color: '#ff0000'
- width: 3
- type: bezier
-````
-
-- `animated`: Set to `true` to enable animated edges. This make all edges dashed and animated.
-- `color`: Define the color of the edges using a hex code.
-- `width`: Set the width of the edges in pixels.
-- `type`: Choose the type of edge. Options include `bezier` or `smoothstep`.
-
-## Unlock Conditions
-
-Nodes can be locked or unlocked based on:
-
-- Completion of other nodes (`after`)
-- Specific dates (`date`)
-- Passwords (`password`)
-
-**Example:**
-
-````yaml
-unlock:
- after:
- - "1"
- date: "2025-10-01"
- password: "webdev2025"
-````
-
-## Progress Tracking
-
-- Users can mark nodes as started or completed.
-- Progress is shown at the top of the map.
-- The progress is saved in your browser. If a id is provided, this will be used
-as a key. If no id is provided the content will be hased and an id will be
-generated.
+::learningmap{id="learningmap-example" height="600px" src="test.learningmap.json"}
-## Resources and Videos
+## Attributes
-- Each node can include resources and a video link for further learning.
+- `id` (required): A unique identifier for the learning map instance.
+- `height` (optional): The height of the learning map container (e.g., `600px`, `100%`).
+- `src` (required): The path to the JSON file that defines the learning map structure.
-## Debug Node
+## Editor
-You can display a debug node by pressing **Ctrl + Space** while viewing the learning map.
+You should use the learningmap editor to create and manage your learning maps. The editor provides a user-friendly interface to design your learning paths and export them as JSON files.
-- The debug node shows the position (`x`, `y`), width, and height of all nodes in the map.
-- This is especially useful when you want to create a custom background image that fits the layout of your learning map.
+[Open Learningmap Editor](https://learningmap.openpatch.org/editor)
diff --git a/website/en/book/elements/learningmap.svg b/website/en/book/elements/learningmap.svg
deleted file mode 100644
index d8dba9a9..00000000
--- a/website/en/book/elements/learningmap.svg
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
diff --git a/website/en/book/elements/test.learningmap.json b/website/en/book/elements/test.learningmap.json
new file mode 100644
index 00000000..ce041615
--- /dev/null
+++ b/website/en/book/elements/test.learningmap.json
@@ -0,0 +1,79 @@
+{
+ "nodes": [
+ {
+ "id": "node1",
+ "type": "task",
+ "position": {
+ "x": 135,
+ "y": 105
+ },
+ "data": {
+ "label": "Getting Started",
+ "summary": "Introduction to the topic",
+ "description": "Learn the fundamentals",
+ "resources": [
+ {
+ "label": "Documentation",
+ "url": "https://example.com/docs"
+ }
+ ]
+ }
+ },
+ {
+ "id": "node2",
+ "type": "topic",
+ "position": {
+ "x": 100,
+ "y": 300
+ },
+ "data": {
+ "label": "Advanced Topics",
+ "summary": "Deep dive into advanced concepts",
+ "unlock": {
+ "after": [
+ "node1"
+ ]
+ },
+ "completion": {
+ "needs": [
+ "node1"
+ ]
+ }
+ }
+ },
+ {
+ "id": "background-node3",
+ "type": "image",
+ "position": {
+ "x": -180,
+ "y": -45
+ },
+ "data": {
+ "src": "",
+ "data": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgaWQ9InN2ZzEyMDgxIgogICB2ZXJzaW9uPSIxLjEiCiAgIHZpZXdCb3g9IjAgMCAxMjUuOTQ5MDIgMTI1Ljk0ODYxIgogICBoZWlnaHQ9IjEyNS45NDg2MW1tIgogICB3aWR0aD0iMTI1Ljk0OTAybW0iCiAgIHNvZGlwb2RpOmRvY25hbWU9Im9wZW5wYXRjaC1jaXJjbGUtd2hpdGUuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjQgKGU3YzNmZWIxMDAsIDIwMjQtMTAtMDkpIgogICBpbmtzY2FwZTpleHBvcnQtZmlsZW5hbWU9Ii9ob21lL21iYXJrbWluL0Nsb3VkL1Byb2pla3RlL29wZW5wYXRjaC9sb2dvX25ldWVfZmFyYmVuX2NpcmNsZS5wbmciCiAgIGlua3NjYXBlOmV4cG9ydC14ZHBpPSIxNTcuNzQ0ODYiCiAgIGlua3NjYXBlOmV4cG9ydC15ZHBpPSIxNTcuNzQ0ODYiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMSIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGdyaWR0b2xlcmFuY2U9IjEwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMTMxIgogICAgIGlkPSJuYW1lZHZpZXcyNCIKICAgICBzaG93Z3JpZD0iZmFsc2UiCiAgICAgZml0LW1hcmdpbi10b3A9IjAiCiAgICAgZml0LW1hcmdpbi1sZWZ0PSIwIgogICAgIGZpdC1tYXJnaW4tcmlnaHQ9IjAiCiAgICAgZml0LW1hcmdpbi1ib3R0b209IjAiCiAgICAgaW5rc2NhcGU6em9vbT0iMC45NTA0NTk2NyIKICAgICBpbmtzY2FwZTpjeD0iMTY0LjEzMTExIgogICAgIGlua3NjYXBlOmN5PSIyNjkuMzQzMzYiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjAiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJnOTEyLTg5IgogICAgIGlua3NjYXBlOmRvY3VtZW50LXJvdGF0aW9uPSIwIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjEiCiAgICAgaW5rc2NhcGU6ZG9jdW1lbnQtdW5pdHM9Im1tIgogICAgIGlua3NjYXBlOnNob3dwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIgLz4KICA8ZGVmcwogICAgIGlkPSJkZWZzMTIwNzUiPgogICAgPGNsaXBQYXRoCiAgICAgICBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgICAgIGlkPSJjbGlwUGF0aDE4LTktNiI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCw0MDAgSCA1NTAgViAwIEggMCBaIgogICAgICAgICBpZD0icGF0aDE2LTktNyIKICAgICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEyMDc4Ij4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZwogICAgIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk3Ljc2NTUwOCw0Ni4zMzQ3MDgpIgogICAgIGlkPSJsYXllcjEiPgogICAgPGcKICAgICAgIGlkPSJnMTEyMiIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuMzUyNzc3NzcsMCwwLC0wLjM1Mjc3Nzc3LC0xNi43OTk1ODEsMjU2LjYwMjU0KSI+CiAgICAgIDxnCiAgICAgICAgIGlkPSJnOTEyLTg5IgogICAgICAgICB0cmFuc2Zvcm09InJvdGF0ZSgtNDUsNDkzLjA0MTQ3LDQxNi4xNDU2NykiPgogICAgICAgIDxlbGxpcHNlCiAgICAgICAgICAgc3R5bGU9ImZpbGw6bm9uZTtmaWxsLW9wYWNpdHk6MTtzdHJva2U6I2ZmZmZmZjtzdHJva2Utd2lkdGg6OS44ODkxMztzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGltaXQ6NDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgICAgICBpZD0icGF0aDg3MiIKICAgICAgICAgICBjeD0iOTguODUxNjMxIgogICAgICAgICAgIGN5PSItMjA5LjY5MDcyIgogICAgICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuNzA3MTA2NzgsMC43MDcxMDY3OCwwLjcwNzEwNjc4LC0wLjcwNzEwNjc4LDAsMCkiCiAgICAgICAgICAgcng9IjEyMy4wNjM4MiIKICAgICAgICAgICByeT0iMTIzLjA2MzQiIC8+CiAgICAgICAgPHBhdGgKICAgICAgICAgICBpZD0icmVjdDY0LTMtMCIKICAgICAgICAgICBkPSJtIC0xODkuNDk1NjksMjIwLjA3MDcxIDEwOS4yNzE3MDgsMTA5LjI3MTcxIGMgMS4xNDU4MDUsMS4xNDU4IDIuOTkwNjY5LDEuMTQ1OCA0LjEzNjQ3MywtMWUtNSBMIDMzLjE4NDIwMiwyMjAuMDcwNyBjIDEuMTQ1ODAxLC0xLjE0NTggMS4xNDU4MDUsLTIuOTkwNjYgMCwtNC4xMzY0NyBMIC03Ni4wODc1MDgsMTA2LjY2MjUyIGMgLTEuMTQ1ODAyLC0xLjE0NTggLTIuOTkwNjY4LC0xLjE0NTggLTQuMTM2NDY5LDAgTCAtMTg5LjQ5NTY5LDIxNS45MzQyMyBjIC0xLjE0NTgsMS4xNDU4MSAtMS4xNDU4LDIuOTkwNjcgMCw0LjEzNjQ4IHoiCiAgICAgICAgICAgc3R5bGU9Im9wYWNpdHk6MTtmaWxsOm5vbmU7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOiNmZmZmZmY7c3Ryb2tlLXdpZHRoOjcuMTk2NTk7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1taXRlcmxpbWl0OjQ7c3Ryb2tlLWRhc2hhcnJheTpub25lO3N0cm9rZS1vcGFjaXR5OjEiCiAgICAgICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgLz4KICAgICAgICA8cGF0aAogICAgICAgICAgIGlkPSJwYXRoMjItNy0wIgogICAgICAgICAgIHN0eWxlPSJvcGFjaXR5OjE7ZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTpub25lO3N0cm9rZS13aWR0aDowLjcxNzA5MiIKICAgICAgICAgICBkPSJtIC03OS41NTQwNjMsMTgxLjEzMDYxIGMgMC42NjkwNDcsMC4wNDggMS4zMzg4MTEsLTAuMjYzMTggMi4wMDc4NTgsLTAuOTMyMjIgbCA2LjA1OTQyNiwtNi4wNTk0MyBjIDAuMDk1MzcsLTAuMDk1NCAwLjE5MTQ2NCwtMC4xOTE0NiAwLjI4NjgzNywtMC4yODY4NCBsIDcuNTI5NDY1LC03LjUyOTQ2IGMgMS4yNDI3MiwtMS4yNDI3MiAxLjI0MjcyLC0yLjQ5NzYzIDAsLTMuNzY0NzMgbCAtMTEuODMyMDE2LC0xMS43OTYxNyBjIC0xLjI0MjcyMSwtMS4yNDI3MiAtMi40ODYxNTgsLTEuMjQyNzIgLTMuNzI4ODc4LDAgbCAtNy41Mjk0NjUsNy41Mjk0NyBjIC0wLjA5NTM3LDAuMDk1NCAtMC4xNjcwODMsMC4yMTUxMyAtMC4yMTUxMjgsMC4zNTg1NCBsIC0wLjA3MTcxLDAuMDM1OSAtMC4wNzE3MSwtMC4wMzU5IC01Ljk4NzcxOCw1Ljk4NzcyIGMgLTEuMjY3MTAxLDEuMjQyNzIgLTEuMjY3MTAxLDIuNDg2MTYgMCwzLjcyODg4IGwgMTEuNzk2MTYyLDExLjgzMjAyIGMgMC41OTczMzgsMC41NzM2NyAxLjE4MzIwMiwwLjg4NDE3IDEuNzU2ODc1LDAuOTMyMjIgbSAxMS4yMjI0ODksOTYuMTI2MTcgYyAxLjI5MDc2NSwtMS4yNDI3MiAxLjMxNDQyOSwtMi41MDk4MyAwLjA3MTcxLC0zLjgwMDU5IGwgLTExLjgzMjAxNywtMTEuNzYwMzEgYyAtMS4yOTA3NjUsLTEuMjkwNzYgLTIuNTgxNTMsLTEuMzE0NDMgLTMuODcyMjk2LC0wLjA3MTcgbCAtNy41NjUzMTksNy41Mjk0NyBjIC0xLjI0MjcyMSwxLjI5MDc2IC0xLjIxOTA1NywyLjU4MTUzIDAuMDcxNzEsMy44NzIyOSBsIDExLjkwMzcyNSwxMS44MzIwMiBjIDEuMjQyNzIxLDEuMTk1MzkgMi40NjE3NzcsMS4xNzEwMSAzLjY1NzE2OSwtMC4wNzE3IGwgNy41NjUzMiwtNy41Mjk0NiBtIC0zMy4zNDQ3NzYsLTMzLjM0NDc4IC03LjUyOTQ2LDcuNTY1MzIgYyAtMS4yNDI3MiwxLjI5MDc3IC0xLjIxOTA2LDIuNTgxNTMgMC4wNzE3LDMuODcyMyBsIDExLjcyNDQ0OSwxMS43NjAzIGMgMS4yOTA3NjUsMS4yOTA3NyAyLjU2OTM0LDEuMjkwNzcgMy44MzY0NDIsMCBsIDcuNTI5NDY1LC03LjU2NTMxIGMgMS4yOTA3NjUsLTEuMjQyNzIgMS4yOTA3NjUsLTIuNTA5ODMgMCwtMy44MDA1OSBsIC0xMS43NjAzMDgsLTExLjc2MDMxIGMgLTEuMjkwNzY1LC0xLjI5MDc2IC0yLjU4MTUyOCwtMS4zMTQ0MyAtMy44NzIyOTgsLTAuMDcxNyBtIC0yLjExNTQyLC0yLjExNTQyIGMgMS4yOTA3NywtMS4yNjcxIDEuMzAyOTYsLTIuNTIyMDEgMC4wMzU5LC0zLjc2NDczIGwgLTExLjc5NjE3LC0xMS43OTYxNiBjIC0xLjI0MjcyLC0xLjI2NzExIC0yLjUwOTgyLC0xLjI2NzExIC0zLjgwMDU4LDAgbCAtNy41NjUzMiw3LjUyOTQ2IGMgLTEuMjQyNzIsMS4yOTA3NyAtMS4yNDI3MiwyLjU1Nzg3IDAsMy44MDA1OSBsIDExLjgzMjAxLDExLjgzMjAxIGMgMS4yNDI3MiwxLjI0MjczIDIuNDg2MTYsMS4yMTkwNiAzLjcyODg4LC0wLjA3MTcgbCA3LjU2NTMyLC03LjUyOTQ3IG0gLTQwLjgzODM4LC0yMS45MDcxNiAxMS44MzIwMSwxMS43OTYxNiBjIDEuMjQyNzIsMS4yNjcxMSAyLjQ4NjE2LDEuMjQyNzMgMy43Mjg4OCwtMC4wNzE3IGwgNy41NjUzMiwtNy41Mjk0NyBjIDEuMjkwNzcsLTEuMjQyNzIgMS4zMTQ0MywtMi40ODYxNiAwLjA3MTcsLTMuNzI4ODggbCAtMTEuODMyMDIsLTExLjgzMjAxIGMgLTEuMjQyNzIsLTEuMjQyNzIgLTIuNTA5ODIsLTEuMjQyNzIgLTMuODAwNTgsMCBsIC03LjU2NTMyLDcuNTI5NDYgYyAtMS4yNDI3MiwxLjI5MDc3IC0xLjI0MjcyLDIuNTY5MzQgMCwzLjgzNjQ0IG0gMTA3LjI3Njk0NiwyNi40MjQ4NCA3LjU2NTMyLC03LjUyOTQ3IGMgMS4yNDI3MiwtMS4yNDI3MiAxLjI0MjcyLC0yLjUwOTgyIDAsLTMuODAwNTggbCAtNjUuMDA0MzgyLC02NC45MzI2NyBjIC0xLjI0MjcyLC0xLjI0MjcyIC0yLjQ4NjE1NywtMS4yNDI3MiAtMy43Mjg4NzcsMCBsIC03LjU2NTMxNyw3LjU2NTMyIGMgLTAuMTE5NzYsMC4xMTk3NSAtMC4yMDI5NCwwLjI1MDk4IC0wLjI1MDk5LDAuMzk0NCAtMC4zMTA1LDAuMjE1MTIgLTAuNTk3MzMsMC40MzAyNSAtMC44NjA1MSwwLjY0NTM4IGwgLTE1Ljc3NjAyLDE1Ljg0NzczIGMgLTAuMjE1MTIsMC4yMTUxMyAtMC40MTgwNiwwLjQ1MzkyIC0wLjYwOTUzLDAuNzE3MDkgLTAuMTY3MDgsMC4wOTU0IC0wLjMyMjY5LDAuMjE1MTMgLTAuNDY2MTEsMC4zNTg1NSBsIC03LjUyOTQ2LDcuNTI5NDYgYyAtMS4yOTA3NywxLjI0MjcyIC0xLjMxNDQzLDIuNDg2MTYgLTAuMDcxNywzLjcyODg4IGwgMTEuODMyMDIsMTEuODMyMDIgYyAxLjI0MjcyLDEuMjQyNzIgMi41MDk4MiwxLjI0MjcyIDMuODAwNTgsMCBsIDcuNTI5NDcsLTcuNTI5NDcgYyAwLjE0MzQyLC0wLjE0MzQyIDAuMjc0NjUsLTAuMjg2ODMgMC4zOTQ0LC0wLjQzMDI1IDAuMjM4NzksLTAuMjE1MTMgMC40NjYxMSwtMC40MzAyNiAwLjY4MTI0LC0wLjY0NTM5IGwgMTQuOTUxMzYzLC0xNS4wMjMwNyAzNy41Mzk3NjEsMzcuNTM5NzYgLTE0LjE2MjU2NSwxNC4xOTg0MiAtMzMuNTI0MDQ5LC0zMy41MjQwNSBjIC0xLjI0MjcyLC0xLjI0MjcyIC0yLjQ4NjE1LC0xLjI0MjcyIC0zLjcyODg4LDAgbCAtNy41NjUzMSw3LjU2NTMyIGMgLTEuMjkwNzcsMS4yNDI3MiAtMS4zMTQ0MywyLjQ4NjE2IC0wLjA3MTcsMy43Mjg4OCBsIDQ3LjI5MjIwNyw0Ny4yMjA1IGMgMS4yNDI3MiwxLjI5MDc3IDIuNTA5ODIyLDEuMzE0NDMgMy44MDA1ODcsMC4wNzE3IGwgNy41NjUzMiwtNy41NjUzMiBjIDAuMDk1MzcsLTAuMDcxNyAwLjE3OTI3MywtMC4xNTU2MSAwLjI1MDk4MiwtMC4yNTA5OCBsIDAuNTM3ODE5LC0wLjUzNzgyIDE2LjM4NTU1LC0xNi4zNDk2OSBjIDAuMTY3MDgyLC0wLjE5MTQ3IDAuMzQ2MzU1LC0wLjM3MDc0IDAuNTM3ODE5LC0wLjUzNzgyIDAuMDIzNjYsLTAuMDk1NCAwLjEwNzU2NCwtMC4xOTE0NyAwLjI1MDk4MiwtMC4yODY4NCBNIDYuMTM4NDIsMjE2LjI2ODExIGMgMS4xNDczNDcxLDEuMTQ3MzUgMS4xNDczNDcxLDIuMzA2ODggMCwzLjQ3Nzg5IGwgLTgyLjU3MzEzMyw4Mi41MzcyOCBjIC0xLjAyNzU5MiwxLjAyNzYgLTIuMDY3Mzc2LDEuMTIzNjkgLTMuMTE5MzUsMC4yODY4NCAtMC4wOTUzNywtMC4wOTU0IC0wLjIxNTEyNywtMC4xOTE0NiAtMC4zNTg1NDYsLTAuMjg2ODQgbCAtODIuNTM3MjgxLC04Mi40NjU1NyBjIC0xLjE0NzM0LC0xLjIxOTA1IC0xLjE0NzM0LC0yLjQwMjI1IDAsLTMuNTQ5NiBsIDgyLjUzNzI4MSwtODIuNTM3MjggMC4zNTg1NDYsLTAuMzU4NTUgYyAxLjA1MTk3NCwtMC43ODg4IDIuMDkxNzU4LC0wLjY0NTM4IDMuMTE5MzUsMC40MzAyNiBMIDYuMTM4NDIsMjE2LjI2ODExIgogICAgICAgICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiIC8+CiAgICAgIDwvZz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo="
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "xy-edge__node2top-node1bottom",
+ "source": "node2",
+ "target": "node1",
+ "sourceHandle": "top",
+ "targetHandle": "bottom",
+ "animated": false,
+ "type": "default",
+ "style": {
+ "stroke": "#94a3b8",
+ "strokeWidth": 2
+ }
+ }
+ ],
+ "settings": {
+ "title": "Sample Learning Roadmap",
+ "background": {
+ "color": "#10844e"
+ }
+ },
+ "version": 1
+}
\ No newline at end of file