0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

概要

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

マインドマップ.png

機能

  • 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;
}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?