//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Drag items to and from combos.
import KeyLines from "keylines";
import data, {
  selectionColour,
  nodeColours,
  nodeBorders,
  ocColours,
  acceptOcColours,
  plusGlyph,
  createGlyph,
} from "./combodrag-data.js";

let chart;
let mode;
let propsToReset = [];
let isCombining = false;

const arrangeCheckbox = document.getElementById("arrange");
const transferCheckbox = document.getElementById("transfer");
const resizeButton = document.getElementById("resize");
const arrangeItemsButton = document.getElementById("arrangeItems");
const arrangeButtons = Array.from(document.querySelectorAll("#rhsForm .btn"));
const arrangeTextElement = document.getElementById("arrangeText");

function isCombo(id) {
  return chart.combo().isCombo(id);
}

function getComboNodeItem(id) {
  const item = chart.getItem(id);
  if (item && item.type === "node") {
    if (isCombo(id)) {
      return item;
    }
    const comboId = item.parentId;
    if (comboId) {
      return chart.getItem(comboId);
    }
  }
  return null;
}

function getAllComboNodes() {
  const comboIdSet = new Set();
  chart.each({ type: "node", items: "all" }, (node) => {
    if (isCombo(node.id)) {
      comboIdSet.add(node.id);
    }
  });
  return comboIdSet;
}

function arrangeAllCombos(options) {
  chart.combo().arrange(Array.from(getAllComboNodes()), options);
}

function handleResizeCombos() {
  arrangeAllCombos({ name: "none" });
}

function handleRespositionCombos() {
  arrangeAllCombos({ resize: false, name: "concentric" });
}

function handleDragStart({ setDragOptions, preventDefault }) {
  if (isCombining) {
    preventDefault();
    return;
  }
  // Allow combo contents to be dragged separately from the top level combo
  setDragOptions({ dragCombos: false });
  propsToReset = [];
}

// Darken the colour of the combo to indicate that if the drag ended here
// the node(s) being dragged would transfer into it
function styleOverCombo(combo) {
  const { id, c } = combo;
  propsToReset.push({
    id,
    oc: { c: ocColours[nodeColours.indexOf(c)] },
  });
  chart.setProperties({
    id,
    oc: { c: acceptOcColours[nodeColours.indexOf(c)] },
  });
}

// Add a + glyph to the node to indicate that if the drag ended here
// a combo would be created containing this node and the node(s) being dragged
function addGlyphToNode(id) {
  propsToReset.push({ id, g: [] });
  chart.setProperties({ id, g: [plusGlyph] });
}

function handleDragOver({ id }) {
  // Clear previous styling
  chart.setProperties(propsToReset);

  // Apply styling to indicate the result of ending the drag here
  if (id !== null) {
    const currentTargetCombo = getComboNodeItem(id);
    if (currentTargetCombo === null) {
      // We're over a node, meaning that if the drag ends here we can create a combo
      addGlyphToNode(id);
    } else {
      // We're over a combo, meaning that if the drag ends here we can transfer into it
      styleOverCombo(currentTargetCombo);
    }
  }
}

async function makeNewCombo(idsToCombine, idBeingDragged) {
  const item = chart.getItem(idBeingDragged);
  const style = {
    c: item.c,
    e: 1.4,
    b: nodeBorders[nodeColours.indexOf(item.c)],
  };
  const openStyle = {
    c: ocColours[nodeColours.indexOf(item.c)],
    b: nodeBorders[nodeColours.indexOf(item.c)],
    bw: 2,
  };
  await chart.combo().combine(
    {
      ids: idsToCombine,
      open: true,
      style,
      glyph: createGlyph,
      openStyle,
      label: "",
    },
    {
      arrange: mode.arrange ? "none" : "concentric",
    }
  );
}

// Get an array of all the nested combos that contain a given node (starting at innermost)
function getAncestors(nodeId) {
  if (nodeId === null) return [];
  const ancestors = [];
  let ancestor = chart.getItem(nodeId).parentId;
  while (ancestor) {
    ancestors.push(ancestor);
    ancestor = chart.getItem(ancestor).parentId;
  }
  return ancestors;
}

function getNodesToTransfer(nodeIds, idToTransferInto) {
  // Exclude nodes from being transferred
  // if a combo that contains them is being transferred
  // or they are already in the combo being transferred into
  return nodeIds.filter((nodeId) => {
    return (
      !getAncestors(nodeId).some((id) => nodeIds.includes(id)) &&
      (chart.getItem(nodeId).parentId ?? null) !== idToTransferInto
    );
  });
}

async function handleDragEnd({ id, dragIds, type, preventDefault }) {
  chart.setProperties(propsToReset);
  if (type === "node") {
    // Check the type it has been dragged onto is the chart background (null) or a node
    const inBackground = chart.getItem(id) === null;
    const isNode = chart.getItem(id)?.type === "node";
    if (inBackground || isNode) {
      // Set a flag to prevent another drag starting while async combo functions are running
      isCombining = true;

      const transferOptions = {
        arrange: mode.arrange ? "none" : "concentric",
        resize: !mode.arrange,
      };

      const currentTargetCombo = getComboNodeItem(id);
      if (currentTargetCombo === null && !inBackground) {
        // Create a new combo
        const idsToCombine = [id, ...dragIds];
        // Ensure all the items being combined are at the top level
        await chart.combo().transfer(idsToCombine, null, transferOptions);
        await makeNewCombo(idsToCombine, id);
      } else {
        const currentTargetComboId = currentTargetCombo
          ? currentTargetCombo.id
          : null;
        const nodesToTransfer = getNodesToTransfer(
          dragIds,
          currentTargetComboId
        );
        // Check there is at least one item to transfer
        if (nodesToTransfer.length > 0) {
          await chart.combo().open(currentTargetComboId);
          await chart
            .combo()
            .transfer(nodesToTransfer, currentTargetComboId, transferOptions);
        }
      }
      // Clear the flag, as it is now safe for another drag to start
      isCombining = false;
    }
    if (!mode.arrange) {
      preventDefault();
    }
  }
}

function setMode() {
  arrangeCheckbox.disabled = !transferCheckbox.checked;
  arrangeCheckbox.checked = arrangeCheckbox.checked && transferCheckbox.checked;
  arrangeTextElement.style.color = transferCheckbox.checked ? "" : "silver";

  mode = {
    arrange: arrangeCheckbox.checked,
    transfer: transferCheckbox.checked,
  };

  removeTransferDragEvents();
  if (mode.transfer) {
    addTransferDragEvents();
  }

  arrangeButtons.forEach((btn) => {
    btn.disabled = !mode.arrange;
  });

  // update whether open combos have resize handles on selection
  const updateRe = Array.from(getAllComboNodes()).map((id) => ({
    id,
    oc: { re: mode.arrange },
  }));
  chart.setProperties(updateRe);
}

function addTransferDragEvents() {
  chart.on("drag-start", handleDragStart);
  chart.on("drag-over", handleDragOver);
  chart.on("drag-end", handleDragEnd);
}

function removeTransferDragEvents() {
  chart.off("drag-start", handleDragStart);
  chart.off("drag-over", handleDragOver);
  chart.off("drag-end", handleDragEnd);
}

function initialiseEvents() {
  addTransferDragEvents();

  arrangeCheckbox.addEventListener("change", setMode);
  transferCheckbox.addEventListener("change", setMode);
  resizeButton.addEventListener("click", handleResizeCombos);
  arrangeItemsButton.addEventListener("click", handleRespositionCombos);
}

async function startKeyLines() {
  const options = {
    logo: { u: "/images/Logo.png" },
    selectedNode: {
      b: selectionColour,
      bw: 4,
      oc: {
        b: selectionColour,
        bw: 4,
      },
    },
    handMode: true,
  };

  chart = await KeyLines.create({ container: "klchart", options });
  chart.load(data);
  chart.zoom("fit");

  setMode();
  initialiseEvents();
}

window.addEventListener("DOMContentLoaded", startKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Combo Dragging</title>
    <link rel="stylesheet" type="text/css" href="/css/keylines.css">
    <link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
    <link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
    <link rel="stylesheet" type="text/css" href="/css/demo.css">
    <script src="/vendor/jquery.js" defer type="text/javascript"></script>
    <script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
    <script src="/combodrag.js" crossorigin="use-credentials" type="module"></script>
  </head>
  <body>
    <div class="chart-wrapper demo-cols">
      <div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
        <div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
          <div class="klchart" id="klchart">
          </div>
        </div>
      </div>
      <div class="rhs citext closed" id="demorhscontent">
        <div class="title-bar">
          <svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
            <text x="0" y="38" style="width: 100%;">Combo Dragging</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <fieldset>
                  <p>Transfer items by dragging them to and from combos.</p>
                  <label class="checkbox">
                    <input id="transfer" type="checkbox" checked>Allow transfer between combos
                  </label>
                  <label class="checkbox" id="arrangeText">
                    <input id="arrange" type="checkbox">Allow manual arrangement of items
                  </label>
                  <input class="btn btn-kl" type="button" value="Arrange items" disabled style="margin-left: 20px;" id="arrangeItems">
                  <input class="btn btn-kl" type="button" value="Resize combos" disabled id="resize">
                </fieldset>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
Loading source