Search

Combo Dragging

Combos

Drag items to and from combos.

Combo Dragging
View live example →

This demo lets you transfer nodes and combos by dragging. The colour-coded nodes make it easy to keep track of what moved where.

Dragging behaviour

With the default demo settings:

  • Start by dragging nodes from a combo to the chart and from the chart to a combo.
  • Now try dragging nodes from one combo to another, including to and from the nested combo.
  • An easy way to create a new combo (from nodes that don't already belong to one) is by dragging and dropping one node onto another.
  • Drag and drop a combo onto a node or other combo. You might find it easier to close the combo first by double-clicking on it. The combo you dragged becomes a nested combo.

The kind of dragging support you give to users depends on your data. If each combo contains fixed data, such as country of origin, you may not want to allow dragging between them. Turn off the Allow transfer between comboscheckbox to see this behaviour.

Arranging and resizing

Use the Allow manual arrangement of items checkbox to position dragged items exactly where you want them:See also Documentation: Combos and Combos ConceptsDemos: Combo Options and Combining Nodes

  • With the checkbox turned off, every time you transfer an item into a combo, the items are rearranged in a circular pattern.The combo also resizes to accommodate changes.
  • With the checkbox turned on, you can position the items yourself.Select the combo to reveal drag handles for resizing. If the combos look untidy once you've finished making manual changes, use the Arrange items and Resize combosbuttons to make improvements automatically.

Key functions used:

import KeyLines from "keylines";
import data, {
  selectionColour,
  nodeColours,
  nodeBorders,
  ocColours,
  acceptOcColours,
  plusGlyph,
  createGlyph,
} from "./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);
const data = {
  type: "LinkChart",
  items: [
    {
      type: "node",
      c: "#3fa331",
      id: "a",
      x: -113.81707343325651,
      y: -222.8009140335388,
      t: "a",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#3fa331",
      id: "b",
      x: 23.014940364684776,
      y: -143.8009140335388,
      t: "b",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#3fa331",
      id: "c",
      x: 23.014940364684804,
      y: 14.199085966461169,
      t: "c",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#905885",
      id: "d",
      x: -591.4117467583621,
      y: 49.10718128128315,
      t: "d",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#905885",
      id: "e",
      x: -591.4117467583621,
      y: -42.247756053464784,
      t: "e",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#3fa331",
      id: "f",
      x: -113.81707343325655,
      y: 93.19908596646123,
      t: "f",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#3fa331",
      id: "g",
      x: -250.6490872311978,
      y: 14.199085966461283,
      t: "g",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#3fa331",
      id: "h",
      x: -250.64908723119788,
      y: -143.80091403353867,
      t: "h",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#28671f",
      parentId: "_combo_7",
    },
    {
      type: "node",
      c: "#905885",
      id: "i",
      x: -519.9875806711622,
      y: -7.851690496375852,
      t: "i",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#905885",
      id: "j",
      x: -502.34726843509503,
      y: 69.43556725853186,
      t: "j",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#905885",
      id: "k",
      x: -551.7743249607115,
      y: 131.41513574906745,
      t: "k",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#905885",
      id: "l",
      x: -631.0491685560128,
      y: 131.41513574906742,
      t: "l",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#00698f",
      id: "m",
      x: -113.81707343325652,
      y: -65.43128624719101,
      t: "m",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#002f41",
      parentId: "_combo_1",
    },
    {
      type: "node",
      c: "#00698f",
      id: "n",
      x: -132.96968277084306,
      y: -136.90979739258205,
      t: "n",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#002f41",
      parentId: "_combo_1",
    },
    {
      type: "node",
      c: "#00698f",
      id: "o",
      x: -42.33856228786547,
      y: -84.58389558477752,
      t: "o",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#002f41",
      parentId: "_combo_1",
    },
    {
      type: "node",
      c: "#905885",
      id: "p",
      x: -680.4762250816293,
      y: 69.43556725853172,
      t: "p",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#905885",
      id: "q",
      x: -662.835912845562,
      y: -7.851690496375944,
      t: "q",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#5f3a58",
      parentId: "_combo_3",
    },
    {
      type: "node",
      c: "#00698f",
      id: "r",
      x: -94.66446409567004,
      y: 6.0472248982000565,
      t: "r",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#002f41",
      parentId: "_combo_1",
    },
    {
      type: "node",
      c: "#00698f",
      id: "s",
      x: -185.2955845786476,
      y: -46.27867690960452,
      t: "s",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#002f41",
      parentId: "_combo_1",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "t",
      x: -458.9314615429433,
      y: -236.45013339727885,
      t: "t",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "u",
      x: -542.9670841378348,
      y: -213.55183789937698,
      t: "u",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "v",
      x: -604.0700221860415,
      y: -274.5238580340921,
      t: "v",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "w",
      x: -580.9128603196883,
      y: -358.24757278912716,
      t: "w",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "x",
      x: -496.8664741183195,
      y: -381.3075260290679,
      t: "x",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
    {
      type: "node",
      c: "#d95f66",
      id: "y",
      x: -435.8729042754569,
      y: -320.6703115097571,
      t: "y",
      fc: "white",
      fbc: "rgba(0,0,0,0)",
      fs: 22,
      bw: 2,
      b: "#bd2d35",
    },
  ],
  combos: {
    _combo_1: {
      type: "node",
      c: "#00698f",
      id: "_combo_1",
      x: -113.81707343325652,
      y: -64.80091403353877,
      hi: false,
      bg: false,
      g: [
        {
          p: "ne",
          c: "#ffa845",
          t: "5",
          b: null,
        },
      ],
      oc: {
        b: "#002f41",
        bw: 2,
        c: "#f1fbff",
        bs: "solid",
        re: false,
        w: 222,
        h: 222,
        la: "concentric",
      },
      b: "#002f41",
      e: 1.4,
      parentId: "_combo_7",
      _combo: {
        nodeIds: ["m", "n", "o", "r", "s"],
        linkIds: [],
        open: true,
        comboScale: 1,
        g: "ne",
        e: 1,
      },
      _offsets: [
        {
          x: 59.99999999999994,
          y: 120.00000000000004,
        },
        {
          x: 60,
          y: -30.000000000000014,
        },
        {
          x: 60,
          y: -179.99999999999994,
        },
        {
          x: -90,
          y: 119.99999999999999,
        },
        {
          x: -90.00000000000006,
          y: -30.000000000000014,
        },
      ],
    },
    _combo_3: {
      type: "node",
      c: "#905885",
      id: "_combo_3",
      x: -591.4117467583621,
      y: 49.10718128128315,
      hi: false,
      bg: false,
      g: [
        {
          p: "ne",
          c: "#ffa845",
          t: "8",
          b: null,
        },
      ],
      oc: {
        b: "#5f3a58",
        bw: 2,
        c: "#f6f1f5",
        bs: "solid",
        re: false,
        w: 256,
        h: 256,
        la: "concentric",
      },
      b: "#5f3a58",
      e: 1.4,
      _combo: {
        nodeIds: ["d", "e", "i", "j", "k", "l", "p", "q"],
        linkIds: [],
        open: true,
        comboScale: 1,
        g: "ne",
        e: 1,
      },
      _offsets: [
        {
          x: 225.36158581269927,
          y: -149.63841418730075,
        },
        {
          x: 225.36158581269927,
          y: -299.63841418730067,
        },
        {
          x: 75.36158581269933,
          y: -149.6384141873007,
        },
        {
          x: 74.78304851238045,
          y: -300.21695148761955,
        },
        {
          x: -75.21695148761961,
          y: 299.78304851238045,
        },
        {
          x: -75.21695148761961,
          y: 149.78304851238036,
        },
        {
          x: -225.2169514876196,
          y: 299.78304851238045,
        },
        {
          x: -225.21695148761955,
          y: 149.78304851238042,
        },
      ],
    },
    _combo_7: {
      type: "node",
      c: "#3fa331",
      id: "_combo_7",
      x: -113.81707343325652,
      y: -64.80091403353877,
      hi: false,
      bg: false,
      g: [
        {
          p: "ne",
          c: "#ffa845",
          t: "11",
          b: null,
        },
      ],
      oc: {
        b: "#28671f",
        bw: 2,
        c: "#effaed",
        bs: "solid",
        re: false,
        w: 392,
        h: 392,
        la: "concentric",
      },
      b: "#28671f",
      e: 1.4,
      _combo: {
        nodeIds: ["a", "b", "c", "f", "g", "h", "_combo_1"],
        linkIds: [],
        open: true,
        comboScale: 1,
        g: "ne",
        e: 1,
      },
      _offsets: [
        {
          x: 115.79693390004553,
          y: 188.65407675718848,
        },
        {
          x: 115.79693390004553,
          y: 38.654076757188406,
        },
        {
          x: 115.79693390004553,
          y: -111.34592324281157,
        },
        {
          x: -34.20306609995441,
          y: 188.65407675718848,
        },
        {
          x: -34.20306609995441,
          y: 38.654076757188406,
        },
        {
          x: -34.20306609995447,
          y: -111.34592324281162,
        },
        {
          x: -244.7816034002733,
          y: -231.92446054313046,
        },
      ],
    },
    nodes: [],
    links: [],
  },
  reveal: [],
  viewSettings: {
    width: 1551,
    height: 1132,
    zoom: 1,
    offsetX: 1515.1144065856934,
    offsetY: 979.1001720428467,
  },
};

export default data;

export const selectionColour = "#ffa845";
export const nodeColours = ["#3fa331", "#00698f", "#905885", "#d95f66"];
export const nodeBorders = ["#28671f", "#002f41", "#5f3a58", "#bd2d35"];
export const ocColours = ["#effaed", "#f1fbff", "#f6f1f5", "#fbeeee"];
export const acceptOcColours = ["#aae3a2", "#7bdcff", "#d1b4cb", "#e89ea3"];

export const plusGlyph = {
  p: "nw",
  t: "+",
  c: selectionColour,
  e: 2,
  b: null,
};

export const createGlyph = {
  p: "ne",
  c: selectionColour,
  t: 8,
  b: null,
};
<!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="@ci/theme/kl/css/keylines.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script type="module" src="./code.js"></script>
  </body>
</html>

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.