206 lines
7.0 KiB
JavaScript
206 lines
7.0 KiB
JavaScript
class Mindmap {
|
|
constructor(options = {}) {
|
|
this.container = options.container;
|
|
this.data = options.data || { id: "root", text: "Root", children: [] };
|
|
this.nodeWidth = options.nodeWidth || 120;
|
|
this.nodeHeight = options.nodeHeight || 40;
|
|
this.hGap = options.hGap || 60;
|
|
this.vGap = options.vGap || 20;
|
|
this.nodes = [];
|
|
this.links = [];
|
|
this.init();
|
|
}
|
|
init() {
|
|
if (!this.container) return;
|
|
this.container.style.position = "relative";
|
|
this.container.style.overflow = "hidden";
|
|
this.container.style.backgroundColor = "#f5f5f5";
|
|
this.viewport = document.createElement("div");
|
|
this.viewport.style.position = "absolute";
|
|
this.viewport.style.width = "10000px";
|
|
this.viewport.style.height = "10000px";
|
|
this.viewport.style.transformOrigin = "0 0";
|
|
this.container.appendChild(this.viewport);
|
|
this.scale = 1;
|
|
this.offsetX = this.container.clientWidth / 2 - 100;
|
|
this.offsetY = this.container.clientHeight / 2 - 50;
|
|
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
this.svg.style.position = "absolute";
|
|
this.svg.style.top = "0";
|
|
this.svg.style.left = "0";
|
|
this.svg.style.width = "100%";
|
|
this.svg.style.height = "100%";
|
|
this.svg.style.pointerEvents = "none";
|
|
this.viewport.appendChild(this.svg);
|
|
this.initEvents();
|
|
this.updateViewport();
|
|
this.render();
|
|
}
|
|
initEvents() {
|
|
let isDragging = false;
|
|
let lastX, lastY;
|
|
this.container.onmousedown = (e) => {
|
|
if (e.target.classList.contains("mindmap-node")) return;
|
|
isDragging = true;
|
|
lastX = e.clientX;
|
|
lastY = e.clientY;
|
|
};
|
|
window.onmousemove = (e) => {
|
|
if (!isDragging) return;
|
|
this.offsetX += e.clientX - lastX;
|
|
this.offsetY += e.clientY - lastY;
|
|
lastX = e.clientX;
|
|
lastY = e.clientY;
|
|
this.updateViewport();
|
|
};
|
|
window.onmouseup = () => {
|
|
isDragging = false;
|
|
};
|
|
this.container.onwheel = (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
const newScale = this.scale * delta;
|
|
if (newScale < 0.1 || newScale > 5) return;
|
|
const rect = this.container.getBoundingClientRect();
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
this.offsetX = mouseX - (mouseX - this.offsetX) * delta;
|
|
this.offsetY = mouseY - (mouseY - this.offsetY) * delta;
|
|
this.scale = newScale;
|
|
this.updateViewport();
|
|
};
|
|
}
|
|
updateViewport() {
|
|
this.viewport.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px) scale(${this.scale})`;
|
|
}
|
|
layout() {
|
|
this.nodes = [];
|
|
this.links = [];
|
|
const compute = (node, x, startY) => {
|
|
const isCollapsed = node._collapsed;
|
|
const childrenHeight = node.children && node.children.length > 0 && !isCollapsed ? node.children.reduce((acc, child) => acc + this.getTotalHeight(child), 0) : this.nodeHeight;
|
|
const y = startY + childrenHeight / 2 - this.nodeHeight / 2;
|
|
const nodeInfo = {
|
|
x,
|
|
y,
|
|
text: node.text,
|
|
id: node.id || Math.random().toString(36).substr(2, 9),
|
|
raw: node
|
|
};
|
|
this.nodes.push(nodeInfo);
|
|
if (node.children && !isCollapsed) {
|
|
let currentY = startY;
|
|
node.children.forEach((child) => {
|
|
const childInfo = compute(child, x + this.nodeWidth + this.hGap, currentY);
|
|
this.links.push({
|
|
x1: x + this.nodeWidth,
|
|
y1: y + this.nodeHeight / 2,
|
|
x2: childInfo.x,
|
|
y2: childInfo.y + this.nodeHeight / 2
|
|
});
|
|
currentY += this.getTotalHeight(child);
|
|
});
|
|
}
|
|
return nodeInfo;
|
|
};
|
|
compute(this.data, 0, 0);
|
|
}
|
|
getTotalHeight(node) {
|
|
if (!node.children || node.children.length === 0 || node._collapsed) {
|
|
return this.nodeHeight + this.vGap;
|
|
}
|
|
return node.children.reduce((acc, child) => acc + this.getTotalHeight(child), 0);
|
|
}
|
|
render() {
|
|
this.layout();
|
|
this.svg.innerHTML = "";
|
|
const existingNodes = this.viewport.querySelectorAll(".mindmap-node");
|
|
existingNodes.forEach((n) => n.remove());
|
|
this.links.forEach((link) => {
|
|
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
const d = `M ${link.x1} ${link.y1} C ${link.x1 + this.hGap / 2} ${link.y1}, ${link.x2 - this.hGap / 2} ${link.y2}, ${link.x2} ${link.y2}`;
|
|
path.setAttribute("d", d);
|
|
path.setAttribute("stroke", "#4A90E2");
|
|
path.setAttribute("fill", "none");
|
|
path.setAttribute("stroke-width", "2");
|
|
this.svg.appendChild(path);
|
|
});
|
|
this.nodes.forEach((node) => {
|
|
const el = document.createElement("div");
|
|
el.className = "mindmap-node";
|
|
if (node.raw._collapsed) el.classList.add("collapsed");
|
|
el.style.position = "absolute";
|
|
el.style.left = `${node.x}px`;
|
|
el.style.top = `${node.y}px`;
|
|
el.style.width = `${this.nodeWidth}px`;
|
|
el.style.height = `${this.nodeHeight}px`;
|
|
el.style.border = "2px solid #4A90E2";
|
|
el.style.borderRadius = "8px";
|
|
el.style.backgroundColor = "#fff";
|
|
el.style.display = "flex";
|
|
el.style.alignItems = "center";
|
|
el.style.justifyContent = "center";
|
|
el.style.fontSize = "14px";
|
|
el.style.fontWeight = "bold";
|
|
el.style.color = "#333";
|
|
el.style.boxShadow = "0 4px 6px rgba(0,0,0,0.1)";
|
|
el.style.cursor = "pointer";
|
|
el.style.userSelect = "none";
|
|
el.style.transition = "transform 0.2s";
|
|
el.innerText = node.text;
|
|
if (node.raw._collapsed) {
|
|
el.style.backgroundColor = "#f0f0f0";
|
|
el.style.borderStyle = "dashed";
|
|
}
|
|
el.onmouseenter = () => el.style.transform = "scale(1.05)";
|
|
el.onmouseleave = () => el.style.transform = "scale(1)";
|
|
el.onclick = (e) => {
|
|
e.stopPropagation();
|
|
node.raw._collapsed = !node.raw._collapsed;
|
|
this.render();
|
|
};
|
|
this.viewport.appendChild(el);
|
|
});
|
|
}
|
|
addNode(parentId, text) {
|
|
const findAndAdd = (node) => {
|
|
if (node.id === parentId || node.text === parentId) {
|
|
if (!node.children) node.children = [];
|
|
node.children.push({ text, id: Math.random().toString(36).substr(2, 9) });
|
|
return true;
|
|
}
|
|
if (node.children) {
|
|
for (const child of node.children) {
|
|
if (findAndAdd(child)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
findAndAdd(this.data);
|
|
this.render();
|
|
}
|
|
removeNode(nodeId) {
|
|
const remove = (node) => {
|
|
if (!node.children) return false;
|
|
const index = node.children.findIndex((child) => child.id === nodeId || child.text === nodeId);
|
|
if (index !== -1) {
|
|
node.children.splice(index, 1);
|
|
return true;
|
|
}
|
|
for (const child of node.children) {
|
|
if (remove(child)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (this.data.id === nodeId || this.data.text === nodeId) {
|
|
console.warn("Cannot remove root node");
|
|
return;
|
|
}
|
|
remove(this.data);
|
|
this.render();
|
|
}
|
|
}
|
|
export {
|
|
Mindmap
|
|
};
|