htmlでマインドマップツールを作る

# 概要
アイディア出しの時にマインドマップをよく使うのですが、会社のPCに野良ソフトを入れるわけにはいきません。
上に掛け合い、インストールの許可をもらうのも面倒なのでHTMLでマインドマップを作成しました。
gasで作成し、Webページ化することで簡易的に公開することもできます。
正直直すべきところはたくさんありますが、一旦使える程度までいったのでメモとしてここに残しておきます。

![マインドマップ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2538469/b15f4405-21bf-f2a8-828d-d05af0355513.png)

# 機能
* CSVファイルの取り込み、出力
* tabを押すことで子ノードを作成
* 同じ座標にノードができないようにする
* 自動で描画領域の拡大
* ノードの整列機能

# CSV
CSVの作りは　uniqueID,parentID,nodeContent　になります。

# コード
``` html: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>
```

``` css: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;
  }
```

```javascript: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;
}

```