概要
アイディア出しの時にマインドマップをよく使うのですが、会社のPCに野良ソフトを入れるわけにはいきません。
上に掛け合い、インストールの許可をもらうのも面倒なのでHTMLでマインドマップを作成しました。
gasで作成し、Webページ化することで簡易的に公開することもできます。
正直直すべきところはたくさんありますが、一旦使える程度までいったのでメモとしてここに残しておきます。
機能
- CSVファイルの取り込み、出力
- tabを押すことで子ノードを作成
- 同じ座標にノードができないようにする
- 自動で描画領域の拡大
- ノードの整列機能
CSV
CSVの作りは uniqueID,parentID,nodeContent になります。
コード
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mind Map</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div>
<button id="new-map">新規作成</button>
<button id="add-node">ノード追加</button>
<button id="save-button">保存</button>
<button id="import-csv-button">CSV読み込み</button>
<button id="sort-node">ノード整列</button>
<input type="file" id="csv-file-input" accept=".csv" style="display:none;" />
</div>
<div id="mind-map"></div>
<script src="index.js"></script>
</body>
</html>
style.css
/* Mind map container */
#mind-map {
position: relative;
width: 100%;
height: 90vh;
border: 1px solid #ddd;
overflow: auto;
background-color: #f0f0f0;
}
svg {
position: absolute;
top: 0;
left: 0;
}
/* Buttons */
button {
margin: 5px;
padding: 10px 15px;
border: none;
border-radius: 5px;
background-color: #3498db;
color: white;
font-size: 14px;
cursor: pointer;
}
button:hover {
background-color: #2ecc71;
}
/* Node container */
.node {
position: absolute;
width: 200px;
padding: 10px;
border: 2px solid #3498db;
border-radius: 10px;
background-color: #f9f9f9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: grab;
display: flex;
align-items: center;
}
.node .handle {
font-weight: bold;
color: #3498db;
cursor: grab;
margin-right: 10px;
}
.node .content {
flex-grow: 1;
font-size: 14px;
color: #333;
word-wrap: break-word;
outline: none;
border: none;
}
.node .content:focus {
border: none;
outline: none;
}
/* Input box inside content */
.node input {
width: 100%;
font-size: 14px;
color: #333;
border: none;
outline: none;
padding: 5px;
}
/* Focused node style */
.node.selected {
border-color: #e74c3c;
}
index.js
const mindMap = document.getElementById("mind-map");
const newMapButton = document.getElementById("new-map");
const addNodeButton = document.getElementById("add-node");
const sortButton = document.getElementById("sort-node");
let nodes = [];
let nodeIdCounter = 0;
let lines = [];
let selectedNode = null;
// SVG for drawing lines
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.style.position = "absolute";
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.top = "0";
svg.style.left = "0";
mindMap.appendChild(svg);
sortButton.addEventListener("click", ()=>{
// mindMap.innerHTML = "";
var datas = getAllNode();
recreateNodesFromCSV(datas);
});
// New map
newMapButton.addEventListener("click", () => {
mindMap.innerHTML = "";
var oldLines = svg.querySelectorAll("line");
oldLines.forEach(line => line.parentNode.removeChild(line));
mindMap.appendChild(svg);
nodes = [];
lines = [];
nodeIdCounter = 0;
selectedNode = null;
// Create parent node
const uniqueID = ++nodeIdCounter;
const parentNode = createNodeElement(uniqueID, "", 100, 300, null);
mindMap.appendChild(parentNode);
nodes.push({ uniqueID, parentID: null, nodeContent: "", x: 100, y: 300 });
});
document.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
event.preventDefault(); // デフォルトのタブ移動を防ぐ
addNodeButton.click();
}
});
// Add node
addNodeButton.addEventListener("click", () => {
if (!selectedNode) {
alert("親となるノードを選択してください!");
return;
}
const parentID = parseInt(selectedNode.dataset.uniqueID, 10); // Retrieve ID from selected node
const parentNode = nodes.find((n) => n.uniqueID === parentID);
if (!parentNode) {
alert("親ノードが見つかりません!");
return;
}
console.log(`親ノードは ${parentNode.nodeContent}`);
console.log(parentNode);
const uniqueID = ++nodeIdCounter;
const {x,y} = getNextAvailablePosition(parentNode);
const childNode = createNodeElement(uniqueID, "", x, y, parentID);
mindMap.appendChild(childNode);
nodes.push({ uniqueID, parentID, nodeContent: "", x, y });
drawLine(parentID, uniqueID);
updateSvgBounds(); // SVG サイズ更新
});
function getNextAvailablePosition(parentNode) {
const spacingX = 250; // X軸の間隔
const spacingY = 70; // Y軸の間隔
const allPositions = nodes.map(node => ({ x: node.x, y: node.y }));
const parentX = parentNode.x;
const parentY = parentNode.y;
let proposedX = parentX + spacingX; // 初期のX座標
let proposedY = parentY - 70; // 初期のY座標
while (allPositions.some(pos => pos.x === proposedX && pos.y === proposedY)) {
proposedY += spacingY;
if (proposedY > window.innerHeight) {
proposedY = parentY;
proposedX += spacingX;
}
}
return { x: proposedX, y: proposedY };
}
// Create node element
function createNodeElement(uniqueID, nodeContent, x, y, parentID) {
const node = document.createElement("div");
node.className = "node";
node.style.left = `${x}px`;
node.style.top = `${y}px`;
node.dataset.uniqueID = uniqueID; // Assign ID to dataset attribute
const handle = document.createElement("div");
handle.className = "handle";
handle.textContent = "::";
node.appendChild(handle);
const contentDiv = document.createElement("div");
contentDiv.className = "content";
const inputBox = document.createElement("input");
inputBox.value = nodeContent;
inputBox.addEventListener("blur", () => {
const nodeData = nodes.find((n) => n.uniqueID === uniqueID);
if (nodeData) {
nodeData.nodeContent = inputBox.value;
}
});
contentDiv.appendChild(inputBox);
node.appendChild(contentDiv);
node.addEventListener("click", () => {
if (selectedNode) {
selectedNode.classList.remove("selected");
}
selectedNode = node;
selectedNode.classList.add("selected");
});
handle.addEventListener("mousedown", (e) => {
let offsetX = e.clientX - node.offsetLeft;
let offsetY = e.clientY - node.offsetTop;
const onMouseMove = (event) => {
node.style.left = `${event.clientX - offsetX}px`;
node.style.top = `${event.clientY - offsetY}px`;
const nodeData = nodes.find((n) => n.uniqueID === uniqueID);
if (nodeData) {
nodeData.x = parseInt(node.style.left, 10);
nodeData.y = parseInt(node.style.top, 10);
}
updateLines();
updateSvgBounds();
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
inputBox.focus();
return node;
}
function drawLine(parentID, childID) {
const parentNode = nodes.find(n => n.uniqueID === parentID);
const childNode = nodes.find(n => n.uniqueID === childID);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("stroke", "#000");
line.setAttribute("stroke-width", "2");
updateLinePosition(line, parentNode, childNode);
svg.appendChild(line);
lines.push({ line, parentID, childID });
}
function updateLinePosition(line, parentNode, childNode) {
line.setAttribute("x1", parentNode.x + 100);
line.setAttribute("y1", parentNode.y + 20);
line.setAttribute("x2", childNode.x + 100);
line.setAttribute("y2", childNode.y + 20);
}
function updateLines() {
lines.forEach(({ line, parentID, childID }) => {
const parentNode = nodes.find(n => n.uniqueID === parentID);
const childNode = nodes.find(n => n.uniqueID === childID);
updateLinePosition(line, parentNode, childNode);
});
}
function updateSvgBounds() {
const bounds = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
nodes.forEach((node) => {
const nodeRight = node.x + 250;
const nodeBottom = node.y + 50;
if (node.x < bounds.minX) bounds.minX = node.x;
if (node.y < bounds.minY) bounds.minY = node.y;
if (nodeRight > bounds.maxX) bounds.maxX = nodeRight;
if (nodeBottom > bounds.maxY) bounds.maxY = nodeBottom;
});
svg.style.width = `${bounds.maxX - bounds.minX + 100}px`;
svg.style.height = `${bounds.maxY - bounds.minY + 100}px`;
}
function convertToCSV(data) {
const rows = data.map(row => `${row.uniqueID},${row.parentID},${row.nodeContent}`);
return `uniqueID,parentID,nodeContent\n${rows.join("\n")}`;
}
document.getElementById("save-button").addEventListener("click", () => {
var csvData = getAllNode();
const csvContent = convertToCSV(csvData);
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
const targetElement = document.querySelector('[data-unique-i-d="1"]');
if (targetElement) {
const inputElement = targetElement.querySelector('input');
if (inputElement) {
var fileName = inputElement.value;
}
}
link.setAttribute("download", fileName);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
function parseCSV(csvText) {
const lines = csvText.split("\n").map(line => line.trim());
const headers = lines[0].split(",");
const rows = lines.slice(1);
return rows.map(row => {
const values = row.split(",");
const obj = {};
headers.forEach((header, index) => {
obj[header] = isNaN(values[index]) ? values[index] : Number(values[index]);;
});
return obj;
});
}
function recreateNodesFromCSV(data) {
nodes.length = 0;
document.querySelectorAll(".node").forEach(node => node.remove());
document.querySelectorAll("line").forEach(line => line.remove());
const allPositions = [];
data.forEach((item, index) => {
const uniqueID = item.uniqueID;
const parentID = item.parentID === "uniqueKey" ? null : item.parentID;
const nodeContent = item.nodeContent;
let baseX = 100;
let baseY = 300;
if (parentID) {
const parentNode = nodes.find(node => node.uniqueID === parentID);
if (parentNode) {
baseX = parentNode.x + 250;
baseY = parentNode.y - 70;
}
}
const { x, y } = getNextAvailablePositionForImport(baseX, baseY, allPositions);
const newNode = createNodeElement(uniqueID, nodeContent, x, y, parentID);
mindMap.appendChild(newNode);
nodes.push({ uniqueID, parentID, nodeContent, x, y });
allPositions.push({ x, y });
if (parentID) {
drawLine(parentID, uniqueID);
}
});
updateSvgBounds();
}
document.getElementById("import-csv-button").addEventListener("click", () => {
document.getElementById("csv-file-input").click();
});
document.getElementById("csv-file-input").addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const csvContent = e.target.result;
const data = parseCSV(csvContent);
var max = 0;
data.forEach(row => {
if(max < row.uniqueID) max = row.uniqueID;
});
nodeIdCounter = ++ max;
recreateNodesFromCSV(data);
console.log
};
reader.readAsText(file);
});
function getNextAvailablePositionForImport(baseX, baseY, allPositions) {
const spacingX = 150;
const spacingY = 100;
let proposedX = baseX;
let proposedY = baseY;
while (allPositions.some(pos => pos.x === proposedX && pos.y === proposedY)) {
proposedY += spacingY;
if (proposedY > window.innerHeight) {
proposedY = baseY;
proposedX += spacingX;
}
}
return { x: proposedX, y: proposedY };
}
function getAllNode(){
var allNodes = nodes.map(node => {
const parentID = node.parentID ? node.parentID : "uniqueKey";
return {
uniqueID: node.uniqueID,
parentID,
nodeContent: node.nodeContent || ""
};
});
allNodes = allNodes.sort((a, b) => {
if (a.parentID === 'uniqueKey') return -1;
if (b.parentID === 'uniqueKey') return 1;
if (a.parentID < b.parentID) return -1;
if (a.parentID > b.parentID) return 1;
return 0;
});
return allNodes;
}