UNPKG

create-gojs-kit

Version:

A CLI for downloading GoJS samples, extensions, and docs

3,214 lines 116 kB
<!DOCTYPE html>
<html lang="en">
  <head>
    
    <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"/>

<meta name="description" content="An editor for defining planograms: visual displays of merchandise." />
<meta itemprop="description" content="An editor for defining planograms: visual displays of merchandise." />
<meta property="og:description" content="An editor for defining planograms: visual displays of merchandise." />
<meta name="twitter:description" content="An editor for defining planograms: visual displays of merchandise." />


<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="../assets/css/style.css">
<!-- Copyright 1998-2025 by Northwoods Software Corporation. -->


<meta itemprop="name" content="Planogram Editor: Drag and Drop sodas onto Vending Machine" />
<meta property="og:title" content="Planogram Editor: Drag and Drop sodas onto Vending Machine" />
<meta name="twitter:title" content="Planogram Editor: Drag and Drop sodas onto Vending Machine" />


<meta property="og:image" content="https://gojs.net/latest/assets/images/screenshots/vendingPlanogram.png" />
<meta itemprop="image" content="https://gojs.net/latest/assets/images/screenshots/vendingPlanogram.png" />
<meta name="twitter:image" content="https://gojs.net/latest/assets/images/screenshots/vendingPlanogram.png" />

<meta property="og:url" content="https://gojs.net/latest/samples/vendingPlanogram.html" />
<meta property="twitter:url" content="https://gojs.net/latest/samples/vendingPlanogram.html" />

<meta name="twitter:card" content="summary_large_image" />
<meta property="og:type" content="website" />
<meta property="twitter:domain" content="gojs.net" />

    <title>
      Planogram Editor: Drag and Drop sodas onto Vending Machine | GoJS Diagramming Library
    </title>
  </head>

  <body>
    <!-- This top nav is not part of the sample code -->
    <nav id="navTop" class=" w-full h-[var(--topnav-h)] z-30 bg-white border-b border-b-gray-200">
  <div class="max-w-screen-xl mx-auto flex flex-wrap items-start justify-between px-4">
    <a class="text-white bg-nwoods-primary font-bold !leading-[calc(var(--topnav-h)_-_1px)] my-0 px-2 text-4xl lg:text-5xl logo"
       href="../">
      GoJS
    </a>
    <div class="relative">
      <button id="topnavButton" class="h-[calc(var(--topnav-h)_-_1px)] px-2 m-0 text-gray-900 bg-inherit shadow-none md:hidden hover:!bg-inherit hover:!text-nwoods-accent hover:!shadow-none" aria-label="Navigation">
        <svg class="h-7 w-7 block" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
          <path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </button>
      <div id="topnavList" class="hidden md:block">
        <div class="absolute right-0 z-30 flex flex-col items-end rounded border border-gray-200 p-4 pl-12 shadow bg-white text-gray-900 font-semibold
                    md:flex-row md:space-x-4 md:items-start md:border-0 md:p-0 md:shadow-none md:bg-inherit">
          <a href="../learn/">Learn</a>
          <a href="../samples/">Samples</a>
          <a href="../intro/">Intro</a>
          <a href="../api/">API</a>
          <a href="../download.html">Download</a>
          <a href="https://forum.nwoods.com/c/gojs/11" target="_blank" rel="noopener">Forum</a>
          <a id="tc" href="https://nwoods.com/contact.html"
            target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/contact.html', 'contact');">Contact</a>
          <a id="tb" href="https://nwoods.com/sales/index.html"
            target="_blank" rel="noopener" onclick="getOutboundLink('https://nwoods.com/sales/index.html', 'buy');">Buy</a>
        </div>
      </div>
    </div>
  </div>
</nav>
<script>
  window.addEventListener("DOMContentLoaded", function () {
    // topnav
    var topButton = document.getElementById("topnavButton");
    var topnavList = document.getElementById("topnavList");
    if (topButton && topnavList) {
      topButton.addEventListener("click", function (e) {
        topnavList
          .classList
          .toggle("hidden");
        e.stopPropagation();
      });
      document.addEventListener("click", function (e) {
        // if the clicked element isn't the list, close the list
        if (!topnavList.classList.contains("hidden") && !e.target.closest("#topnavList")) {
          topButton.click();
        }
      });

      // set active <a> element
      var url = window
        .location
        .href
        .toLowerCase();
      var aTags = topnavList.getElementsByTagName('a');
      for (var i = 0; i < aTags.length; i++) {
        var lowerhref = aTags[i]
          .href
          .toLowerCase();
        if (lowerhref.endsWith('.html')) 
          lowerhref = lowerhref.slice(0, -5);
        if (url.startsWith(lowerhref)) {
          aTags[i]
            .classList
            .add('active');
          break;
        }
      }
    }
  });
</script>
    <div class="flex flex-col prose">
      <div class="w-full max-w-screen-xl mx-auto">
        <!-- * * * * * * * * * * * * * -->
        <!-- Start of GoJS sample code -->
        <script src="https://cdn.jsdelivr.net/npm/gojs@3.1.0"></script>

        

        <div id="allSampleContent" class="p-4 w-full">
          
            
            
    <style>
      :root {
        --light: rgba(235, 243, 235, 1);
        --dark: rgba(89, 99, 89, 1);
        --medium: rgba(197, 206, 197, 1);

        --stroke: #2f3c2f;
      }

      .html-info {
        display: none; /* hide by default */
      }

      #height-input-div {
        padding: 6px;
        border-radius: 6px;
        font: 600 15px sans-serif;
        outline: none;
        background: var(--light);
        border: 2px solid var(--dark);
        color: var(--dark);
      }

      #height-input-div input {
        outline: none; /* no outline when focused */
        border: none;
        background: var(--light);
        width: 40px;
      }

        #height-input-div .arrows {
            display: flex;
            flex-direction: column;
            gap: 3px;
        }

      #height-input-div .arrow {
        width: 16px;
      }

      .arrow {
        right: 8px;
        height: 16px;
        cursor: pointer;
        display: flex;
        justify-content: center; /* center text content (the arrow) horizontally */
        align-items: center; /* center text content (the arrow) vertically */
        background-color: var(--medium);
        border-radius: 3px;
        font-size: 12px;
        color: var(--dark);
        user-select: none; /* no highlight or anything when selected */
      }

      #add-row-input-div {
        flex-direction: column;
        gap: 5px;
        padding: 6px;
        border-radius: 6px;
        font: 600 15px sans-serif;
        outline: none;
        background: var(--light);
        border: 2px solid var(--dark);
        color: var(--dark);
        user-select: none; /* no highlight or anything when selected */
      }

      #add-row-input-div div {
        display: flex;
        gap: 4px;
        width: 130px;
        height: 18px;
      }

      #add-row-input-div input {
        width: 20px;
        font-size: 10px;
        border: none;
        border-radius: 3px;
        color: var(--dark);
        padding: 4px;
        background-color: var(--medium);
      }

      #add-row-input-div p {
        width: 40px;
        font-size: 10px;
        color: var(--dark);
      }

      #fill-shelf-button {
        border: 2px solid var(--stroke);
        border-radius: 3px;
        background-color: var(--light);
        color: var(--stroke);
      }

      input::selection {
        background-color: #6b7b6b;
        color: white;
      }

      /* This CSS is used to create the accordion for the Palettes */
      input[type="radio"] {
        position: absolute;
        opacity: 0;
        z-index: -1;
      }

      input[type="radio"]:checked + .tab-label {
        background: #1a252f;
        font-size: 25px;
      }
      input[type="radio"]:checked + .tab-label::after {
        transform: rotate(90deg);
      }
      input[type="radio"]:checked ~ .tab-content {
        max-height: 100vh;
      }

      /* Accordion styles */
      .tabs {
        overflow: hidden;
      }

      .tab {
        width: 100%;
        color: white;
        overflow: hidden;
      }
      .tab-label {
        font-family: sans-serif;
        display: flex;
        justify-content: space-between;
        padding: 0.5em;
        background: #1f4963;
        cursor: pointer;
      }
      .tab-label:hover {
        background: #627f91;
      }
      .tab-label::after {
        content: "❯";
        width: 1em;
        height: 1em;
        text-align: center;
        transition: all 0.35s;
      }
      .tab-content {
        max-height: 0;
        color: #2c3e50;
        background: white;
      }
      .tab-close {
        display: flex;
        justify-content: flex-end;
        padding: 1em;
        font-size: 0.75em;
        background: #2c3e50;
        cursor: pointer;
      }
      .tab-close:hover {
        background: #1a252f;
      }

      .checkbox-wrapper input[type="checkbox"] {
        height: 0;
        width: 0;
        visibility: hidden;
      }

      .checkbox-wrapper label {
        cursor: pointer;
        width: 130px;
        height: 25px;
        background: grey;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 100px;
        position: relative;
        color: white;
        font-size: 12px;
      }

      .checkbox-wrapper label:after {
        content: "";
        position: absolute;
        top: 2.5px;
        left: 2.5px;
        width: 25px;
        height: 20px;
        background: #fff;
        border-radius: 90px;
        transition: 0.3s;
      }

      .checkbox-wrapper input:checked + label {
        background: #7e9c8f;
      }

      .checkbox-wrapper input:checked + label:after {
        left: calc(100% - 2.5%);
        transform: translateX(-100%);
      }

      .checkbox-wrapper .slider-text {
        font-family: sans-serif;
        text-decoration: line-through;
        display: inline-block; /* so transform works */
        transform: translateX(10px);
        transition: transform 0.3s, text-decoration 0.3s;
      }

      /* when checked: remove strikethrough and shift left */
      .checkbox-wrapper input:checked + label .slider-text {
        text-decoration: none;
        transform: translateX(-10px);
      }
    </style>

    <script id="code">
      /***********************************************************************
       *                          GLOBAL VARIABLES                           *
       ***********************************************************************/
      const settings = {
        colors: {
          exterior: "#6B7B6B",
          dark: "#4A594A",
          interior: "#E6F0E6",
          stroke: "#2F3C2F",
          keypad: "#B8BCA5",
          highlight: "#FDFDFD",
          shadow: "#4A594A",
          palettes: {
            green: {
              fillColor: "#8FA99E",
              labelColor: "#C3D4CA",
              stroke: "#4B6257",
            },
            blue: {
              fillColor: "#A3BDD9",
              labelColor: "#DCE8F7",
              stroke: "#607B9E",
            },
            beige: {
              fillColor: "#E8DCC1",
              labelColor: "#F5F0E3",
              stroke: "#8C7E56",
            },
          },
        },

        paletteXSpacing: 45,
        paletteYSpacing: 85,
        defaultShelfHeight: 70,

        // if allowTopLevel is false, that means you can't drag sodas onto the diagram background
        allowTopLevel: false,

        // settings the user can change:
        editMode: true,
        allowDuplicates: true,

        // r is rounding on some things
        r: 10,
      };

      const sodaCategories = ["can", "bottle"];

      // will be defined in init()
      let myDiagram;
      let palette;
      let vendingMachinePalette;

      // when you right click a soda (fill shelf button pops up) it stores the data
      // fillShelf() uses the data - to know:
      // 1. what type of soda to fill the shelf with
      // 2. what group & shelf to fill
      let sodaRightClickedData;

      /***********************************************************************
       *                       CUSTOM INPUT EDITOR                           *
       ***********************************************************************/

      // Create an HTMLInfo and dynamically create some HTML to show/hide
      const customEditor = new go.HTMLInfo();

      // the onclick functions on the arrows for the height input use this
      function changeHeightInput(change) {
        inputDiv = document.getElementById("height-input-div");
        inputBox = inputDiv.querySelector("input");
        inputBox.value = Math.max(
          parseInt(inputBox.value) + change,
          settings.defaultShelfHeight - 20
        );
      }

      function placeInput(div, pos, divWidth, divHeight) {
        div.style.left = `${pos.x - divWidth / 2}px`;
        div.style.top = `${pos.y - divHeight / 2}px`;
      }

      function handleAddShelfInput(textBlock, diagram, tool, pos, inputDiv) {
        const rowInput = document.getElementById("row-input");
        const heightInput = document.getElementById("height-input");

        function addRow(itemArray, shelfNum, groupKey) {
          let numRows = parseInt(rowInput.value);
          let height = parseInt(heightInput.value);
          if (height < settings.defaultShelfHeight - 20) {
            height = settings.defaultShelfHeight - 20;
          }
          if (isNaN(height)) height = settings.defaultShelfHeight;
          if (isNaN(numRows)) numRows = 1;

          myDiagram.startTransaction("Add row(s)");
          for (let i = 0; i < numRows; i++) {
            // insert a new shelf at the specified index
            myDiagram.model.insertArrayItem(itemArray, shelfNum, {
              height: height,
            });
          }

          for (const soda of allSodas()) {
            if (soda.group === groupKey && soda.shelf >= shelfNum) {
              myDiagram.model.setDataProperty(
                soda,
                "shelf",
                soda.shelf + numRows
              );
            }
          }
          // we need this to update locations before transaction is finished
          myDiagram.findNodeForKey(groupKey).ensureBounds();
          updateInvisibleCells();
          updateHighlights();
          myDiagram.updateAllTargetBindings();
          myDiagram.commitTransaction("Add row(s)");
        }

        rowInput.value = "1";
        heightInput.value = "70";

        const shelfData = getDataFromGraphObject(textBlock);
        const groupData = findGroupData(shelfData);
        const shelfNum = groupData.itemArray.indexOf(shelfData);
        const groupKey = groupData.key;
        // add row above
        inputDiv.querySelector(".arrow.up").onclick = () => {
          addRow(groupData.itemArray, shelfNum, groupKey);
          customEditor.hide(diagram, tool);
        };
        // add row below
        inputDiv.querySelector(".arrow.down").onclick = () => {
          addRow(groupData.itemArray, shelfNum + 1, groupKey);
          customEditor.hide(diagram, tool);
        };

        placeInput(inputDiv, pos, 142, 72);
      }

      function handleChangeShelfHeightInput(
        textBlock,
        diagram,
        tool,
        pos,
        inputDiv,
        inputBox
      ) {
        inputBox.value = textBlock.text;

        // Do a few different things when a user presses a key
        inputBox.addEventListener(
          "keydown",
          (e) => {
            if (e.isComposing) return;
            const key = e.key;
            if (key === "Enter") {
              // Accept on Enter
              customEditor.hide(diagram, tool);
              return;
            } else if (key === "Tab") {
              // Accept on Tab
              customEditor.hide(diagram, tool);
              e.preventDefault();
              return false;
            } else if (key === "Escape") {
              // Cancel on Esc
              tool.doCancel();
            }
          },
          false
        );

        placeInput(inputDiv, pos, 40, 30);
      }

      function handleFillShelfButton(pos, button) {
        const mousePoint = myDiagram.lastInput.viewPoint;
        placeInput(button, mousePoint, 0, 0);
      }

      customEditor.show = (textBlock, diagram, tool) => {
        customEditor._textBlockName = textBlock.name; // for use later in valueFunction

        const loc = textBlock.getDocumentPoint(go.Spot.TopLeft);
        const pos = diagram.transformDocToView(loc);

        let div;
        let inputBox;
        if (textBlock.name === "ADD_SHELF_PLACEHOLDER_TEXT") {
          div = document.getElementById("add-row-input-div");
          handleAddShelfInput(textBlock, diagram, tool, pos, div);
        } else if (textBlock.name === "FILL_SHELF_PLACEHOLDER_TEXT") {
          div = document.getElementById("fill-shelf-button");
          handleFillShelfButton(pos, div);
        } else {
          div = document.getElementById("height-input-div");
          inputBox = div.querySelector("input");
          handleChangeShelfHeightInput(
            textBlock,
            diagram,
            tool,
            pos,
            div,
            inputBox
          );
        }

        div.style.position = "absolute";
        div.style.zIndex = 100; // place it in front of the Diagram

        diagram.div.appendChild(div);
        div.style.display = "flex"; // show the input div
        if (inputBox) {
          inputBox.focus();
        } else {
          div.focus();
        }
      };

      customEditor.hide = (diagram, tool) => {
        let div;
        if (tool.textBlock.name === "ADD_SHELF_PLACEHOLDER_TEXT") {
          div = document.getElementById("add-row-input-div");
        } else if (tool.textBlock.name === "FILL_SHELF_PLACEHOLDER_TEXT") {
          div = document.getElementById("fill-shelf-button");
        } else {
          div = document.getElementById("height-input-div");

          const inputBox = div.querySelector("input");
          diagram.startTransaction("change shelf height");
          tool.textBlock.text = inputBox.value;
          diagram.layoutDiagram(true); // force layout to redo
          updateInvisibleCells();
          updateHighlights();
          diagram.updateAllTargetBindings();
          diagram.commitTransaction("change shelf height");
        }

        if (diagram.div.contains(div)) {
          div.style.display = "none"; // hide the input div
        }
      };

      // This is necessary for HTMLInfo instances that are used as text editors
      customEditor.valueFunction = () => {
        if (customEditor._textBlockName !== "ADD_SHELF_PLACEHOLDER_TEXT" &&
            customEditor._textBlockName !== "FILL_SHELF_PLACEHOLDER_TEXT") {
          return document
            .getElementById("height-input-div")
            .querySelector("input").value;
        } else {
          return "";
        }
      };

      /***********************************************************************
       *                       CUSTOM SHAPE (SODA)                           *
       ***********************************************************************/
      go.Shape.defineFigureGenerator("Soda", (shape, w, h) => {
        const geo = new go.Geometry();
        const mid = w / 2;
        const capWidth = w / 8;
        const capX = [mid - capWidth, mid + capWidth];
        const capY = h / 10;
        const bottomStartY = h / 4;
        // corner radius
        const r =
          shape && shape.parameter1 ? Math.min(shape.parameter1, 18) : 8;
        // start x, start y, filled
        // top left corner of cap
        const fig = new go.PathFigure(capX[0], 0, true);
        geo.add(fig);
        // top right corner of cap
        fig.add(new go.PathSegment(go.SegmentType.Line, capX[1], 0));
        // point between neck and cap (right)
        fig.add(new go.PathSegment(go.SegmentType.Line, capX[1], capY));
        // right side, soda bottle neck
        fig.add(
          new go.PathSegment(
            go.SegmentType.QuadraticBezier,
            w,
            bottomStartY + r,
            w,
            bottomStartY - r / 4
          )
        );
        // bottom right corner
        fig.add(
          new go.PathSegment(
            go.SegmentType.Arc,
            0,
            90, // start angle and sweep angle (from center point)
            w - r,
            h - r, // center x and center y
            r,
            r // radius x and radius y
          )
        );
        // bottom left corner
        fig.add(
          new go.PathSegment(
            go.SegmentType.Arc,
            90,
            90, // start angle and sweep angle (from center point)
            r,
            h - r, // center x and center y
            r,
            r // radius x and radius y
          )
        );
        // left side, soda bottle neck
        fig.add(new go.PathSegment(go.SegmentType.Line, 0, bottomStartY + r));
        fig.add(
          new go.PathSegment(
            go.SegmentType.QuadraticBezier,
            capX[0],
            capY,
            0,
            bottomStartY - r / 4
          ).close()
        );
        const capLineFig = new go.PathFigure(capX[0], capY, false);
        capLineFig.add(new go.PathSegment(go.SegmentType.Line, capX[1], capY));
        geo.add(capLineFig);
        return geo;
      });

      /***********************************************************************
       *                           HANDLE EVENTS                             *
       ***********************************************************************/

      // handles item being dropped onto the vending machine
      function handleVendingMouseDrop(e, grp) {
        let cancelled = false;
        grp.diagram.selection.each((node) => {
          if (cancelled) return;
          if (node instanceof go.Group) {
            animateDrop(new go.Set().add(node));
            return;
          }
          const closestCell = getClosestCell(node);
          if (closestCell === null) {
            grp.diagram.currentTool.doCancel();
            cancelled = true;
            return;
          }

          myDiagram.startTransaction("drop object");
          // reset invisible cell back to being invisible
          closestCell.findObject("SHAPE").fill = "transparent";

          // set shelf location
          myDiagram.model.setDataProperty(
            node.data,
            "shelf",
            closestCell.data.shelf
          );
          myDiagram.model.setDataProperty(
            node.data,
            "coil",
            closestCell.data.coil
          );

          // update visual
          myDiagram.updateAllTargetBindings();

          var ok = grp.addMembers([node], true);
          if (!ok) {
            grp.diagram.currentTool.doCancel();
            return;
          }
          if (node.data.isFromPalette) {
            handleItemFromPalette(node.data);
          }

          myDiagram.commitTransaction("drop object");
        });
      }

      /***********************************************************************
       *                FUNCTIONS FOR MAKING THE TEMPLATES                   *
       ***********************************************************************/

      // =================== REUSABLE FUNCTIONS ===================

      function buttonStyle(obj) {
        return obj
          .set({
            width: 12,
            height: 12,
          })
          .attach({ "ButtonBorder.strokeWidth": 0 });
      }

      // use to make a button
      function button(symbol, click, margin) {
        const btn = go.GraphObject.build("Button", {
          click: click,
        })
          .apply(buttonStyle)
          .add(
            new go.Shape(symbol, {
              strokeWidth: 2,
              stroke: settings.colors.stroke,
            })
          );
        if (margin) {
          btn.margin = margin;
        }
        return btn;
      }

      function getStrokeColor(color) {
        return settings.colors.palettes[color].stroke;
      }

      function sodaStyle(template) {
        template
          .findObject("MAIN_SHAPE")
          .set({
            width: 35,
            fill: settings.colors.dark,
            strokeWidth: 2,
            stroke: settings.colors.stroke,
          })
          .bind(
            "fill",
            "color",
            (color) => settings.colors.palettes[color].fillColor
          )
          .bind("stroke", "color", getStrokeColor);
        template
          .findObject("LABEL_SHAPE")
          .set({
            fill: settings.colors.exterior,
            strokeWidth: 2,
            stroke: settings.colors.stroke,
          })
          .bind(
            "fill",
            "color",
            (color) => settings.colors.palettes[color].labelColor
          )
          .bind("stroke", "color", getStrokeColor);
        template
          .findObject("LABEL_TEXT")
          .set({
            stroke: settings.colors.stroke,
          })
          .bind("stroke", "color", getStrokeColor);
        template.findObject("HIGHLIGHT").set({
          name: "HIGHLIGHT",
          width: 15,
          strokeWidth: 0,
          alignment: go.Spot.Left,
          opacity: 0.4,
          fill: "white",
        });
        template
          .findObject("DUPLICATE_OUTLINE")
          .set({
            fill: null,
            stroke: "red",
            strokeWidth: 2,
            width: 35,
            visible: false,
          })
          .bind("visible", "", (data) => {
            return (
              (data.duplicate && !settings.allowDuplicates) ||
              data.toDelete === true
            );
          })
          .bind("stroke", "toDelete", (toDelete) => {
            return toDelete ? "blue" : "red";
          });

        return template
          .add(new go.TextBlock({ name: "FILL_SHELF_PLACEHOLDER_TEXT" }))
          .set({
            // handle right click
            contextClick: (e, obj) => {
              const tb = obj.findObject("FILL_SHELF_PLACEHOLDER_TEXT");
              e.diagram.commandHandler.editTextBlock(tb);
              sodaRightClickedData = obj.data;
            },
            locationSpot: go.Spot.Center,
            zOrder: 3,
            // it must be in the foreground or you can't click on sodas because of the invisible cells being in front
            mouseDrop: (e, node) => {
              handleVendingMouseDrop(e, node.containingGroup);
            },
          })
          .bindObject("zOrder", "", (obj) => {
            if (obj.isSelected) {
              return highestZOrder() * 4 + 4;
            }
            const data = obj.data;
            const groupData = myDiagram.model.findNodeDataForKey(data.group);
            if (!groupData) return 0;
            const zOrder = groupData.zOrder;
            return zOrder * 4 + 2;

            /* each group has a block of 4 z-orders for nodes related to the group
             * +0: vending machine
             * +1: invisible cells
             * +2: soda
             * +3: highlight
             */
          })
          .bind("location", "", (data) => {
            let node = myDiagram.findNodeForKey(data.key);
            if (!node) node = palette.findNodeForKey(data.key);
            const height = node.actualBounds.height;

            if (data.paletteLocation) {
              const { x, y } = go.Point.parse(data.paletteLocation);
              return new go.Point(x, y - height / 2);
            }

            const { group, shelf, coil } = data;
            if (
              group === undefined ||
              shelf === undefined ||
              coil === undefined
            ) {
              // default
              const soda = myDiagram.findNodeForKey(data.key);
              // it will return 0,0 for items in palette i hope that's fine
              if (soda) {
                return soda.location;
              } else {
                return new go.Point(0, 0);
              }
            }
            const ivc = myDiagram.findNodeForKey(
              "IVC " + group + " " + shelf + " " + coil
            );
            if (ivc) {
              const { x, y } = ivc.location;
              return new go.Point(x, y - height / 2);
            } else {
              return new go.Point(0, 0);
            }
          });
      }

      function makeCornerDecoration(args) {
        // set defaults (what they pass in will override what I'm writing here)
        args = {
          alignment: "TopLeft",
          margin: [0],
          strokeWidth: 5,
          r: 5,
          width: 10,
          height: 10,
          horizontalDotSpacing: 10,
          horizontalDotWidth: 0,
          verticalDotSpacing: 10,
          verticalDotWidth: 0,
          stroke: settings.colors.highlight,
          opacity: 0.7,
          ...args,
        };
        const r = args.r;
        const isRight = args.alignment.includes("Right") ? 1 : -1;
        const isTop = args.alignment.includes("Top") ? 1 : -1;
        let geom = "";
        if (args.horizontalDotWidth > 0) {
          geom = `M${args.horizontalDotSpacing + args.horizontalDotWidth} 0 l${
            isRight * args.horizontalDotWidth
          } 0 m${isRight * args.horizontalDotSpacing} 0`;
        } else {
          geom = "M0 0";
        }
        const sweepFlag = isRight === isTop ? 1 : 0;
        const arcEndX = isRight * r;
        const arcEndY = isTop * r;
        geom += `l${
          isRight * args.width
        } 0 a${r} ${r} 0 0 ${sweepFlag} ${arcEndX} ${arcEndY} l0 ${
          isTop * args.height
        }`;
        if (args.verticalDotWidth > 0) {
          geom += `m0 ${isTop * args.verticalDotSpacing} l0 ${
            isTop * args.verticalDotWidth
          }`;
        }
        return new go.Shape({
          alignment: go.Spot[args.alignment],
          geometryString: geom,
          margin: new go.Margin(...args.margin),
          strokeWidth: args.strokeWidth,
          strokeCap: "round",
          stroke: args.stroke,
          opacity: args.opacity,
        });
      }

      // =================== ONE TIME USE ===================

      const modelTemplate = new go.GraphLinksModel([
        {
          key: 1,
          vendingMachineWidth: 4,
          isGroup: true,
          itemArray: [{}, { height: 100 }, {}, {}, {}],
          zOrder: 1,
        },
        {
          key: 3,
          category: "bottle",
          shelf: 1,
          coil: 1,
          group: 1,
          color: "green",
          duplicate: true,
        },
        {
          key: 4,
          category: "can",
          shelf: 0,
          coil: 3,
          group: 1,
          color: "green",
          duplicate: true,
        },
        {
          key: 5,
          category: "bottle",
          shelf: 1,
          coil: 3,
          group: 1,
          color: "green",
          duplicate: true,
        },
        {
          key: 6,
          category: "can",
          color: "beige",
          shelf: 3,
          coil: 2,
          group: 1,
          duplicate: true,
        },
        {
          key: 2,
          isGroup: true,
          vendingMachineWidth: 3,
          itemArray: [{}, { height: 70 }, {}],
          position: "320 0",
          zOrder: 2,
        },
        {
          key: 7,
          category: "can",
          color: "blue",
          shelf: 0,
          coil: 0,
          group: 2,
          duplicate: true,
        },
        {
          key: 8,
          category: "can",
          color: "blue",
          shelf: 0,
          coil: 2,
          group: 2,
          duplicate: true,
        },
        {
          key: 9,
          category: "can",
          color: "blue",
          shelf: 0,
          coil: 1,
          group: 2,
          duplicate: true,
        },
        {
          key: 10,
          category: "can",
          color: "beige",
          shelf: 1,
          coil: 0,
          group: 2,
          duplicate: true,
        },
        {
          key: 11,
          category: "can",
          color: "beige",
          shelf: 1,
          coil: 1,
          group: 2,
          duplicate: true,
        },
        {
          key: 12,
          category: "can",
          color: "beige",
          shelf: 1,
          coil: 2,
          group: 2,
          duplicate: true,
        },
        {
          key: 13,
          category: "can",
          color: "green",
          shelf: 2,
          coil: 0,
          group: 2,
          duplicate: true,
        },
        {
          key: 14,
          category: "can",
          color: "green",
          shelf: 2,
          coil: 2,
          group: 2,
          duplicate: true,
        },
        {
          key: 15,
          category: "can",
          color: "green",
          shelf: 2,
          coil: 1,
          group: 2,
          duplicate: true,
        },
      ]);

      // the button on that shelves that displays their heights
      // when you click it the HTML pops up to change their heights
      function numberInput() {
        return go.GraphObject.build("Button", {
          height: 12,
          margin: new go.Margin(0, 0, 0, 30),
          "ButtonBorder.strokeWidth": 0,
          click: (e, obj) => {
            const tb = obj.findObject("TEXT");
            e.diagram.commandHandler.editTextBlock(tb);
          },
        }).add(
          new go.TextBlock({
            name: "TEXT",
            text: settings.defaultShelfHeight,
            font: "8px sans-serif",
            strokeWidth: 2,
            stroke: settings.colors.stroke,
          }).bindTwoWay("text", "height", undefined, (t) =>
            Math.max(parseInt(t), settings.defaultShelfHeight - 20)
          )
        );
      }

      function addShelfButton() {
        return new go.Panel("Auto", {
          click: (e, obj) => {
            const tb = obj.findObject("ADD_SHELF_PLACEHOLDER_TEXT");
            e.diagram.commandHandler.editTextBlock(tb);
          },
        }).add(
          go.GraphObject.build("Button", {
            margin: new go.Margin(0, 3, 0, 0),
          })
            .apply(buttonStyle)
            .add(
              new go.Shape("PlusLine", {
                strokeWidth: 2,
                stroke: settings.colors.stroke,
              })
            ),
          new go.TextBlock({
            name: "ADD_SHELF_PLACEHOLDER_TEXT",
          })
        );
      }

      function removeShelfButton() {
        return button("MinusLine", (e, btn) => {
          const { itemArray, groupKey, shelfNum } = getButtonClickedInfo(btn);

          const affectedSodas = [];
          for (const soda of allSodas()) {
            if (soda.group === groupKey && soda.shelf >= shelfNum) {
              affectedSodas.push(soda);
            }
          }

          myDiagram.startTransaction("show modal");
          let sodaDeleteCount = 0;
          for (const soda of affectedSodas) {
            if (soda.shelf === shelfNum) {
              sodaDeleteCount++;
              myDiagram.model.setDataProperty(soda, "toDelete", true);
            }
          }
          if (sodaDeleteCount > 0) {
            showModal(
              sodaDeleteCount,
              () => {
                removeShelf(affectedSodas, shelfNum, itemArray, groupKey);
              },
              btn
            );
            myDiagram.commitTransaction("show modal");
          } else {
            myDiagram.commitTransaction("show modal");
            removeShelf(affectedSodas, shelfNum, itemArray, groupKey);
          }
        }).bind("visible", "", (data) => {
          nodeData = findGroupData(data);
          return nodeData.itemArray.length > 1;
        });
      }

      // number buttons are just visual decoration on the controls btw
      // they are not clickable
      function makeNumberButtons() {
        const r = 2;
        const panel = new go.Panel("Table", {
          width: 46,
          height: 67,
        });
        for (let i = 0; i < 12; i++) {
          panel.add(
            new go.Panel("Auto", {
              row: Math.floor(i / 3),
              column: i % 3,
            }).add(
              // invisible rectangle to make it the right size
              new go.Shape("RoundedRectangle", {
                fill: null,
                stroke: null,
                width: 14,
                height: 14,
              }),
              new go.TextBlock({
                text: i < 9 ? i + 1 : ["*", "0", "#"][i - 9],
                // the 900 makes text thicker
                font: "900 8px sans-serif",
                stroke: settings.colors.stroke,
              }),
              // shadow for aesthetics
              makeCornerDecoration({
                height: 7 - r,
                width: 7 - r,
                alignment: "BottomRight",
                stroke: settings.colors.dark,
                strokeWidth: 1.5,
                opacity: 0.8,
                r: r,
              }),
              makeCornerDecoration({
                alignment: "TopLeft",
                strokeWidth: 1.5,
                height: 2,
                width: 2,
                r: r,
              })
            )
          );
        }
        return panel;
      }

      function leftAddAndDeleteButtons() {
        return new go.Panel("Vertical", {
          // extra space between buttons and window
          margin: new go.Margin(0, 3, 0, 0),
        }).add(
          button("PlusLine", (e, btn) => {
            myDiagram.startTransaction("add column");
            // add 1 to vendingMachineWidth
            const data = getDataFromGraphObject(btn);
            myDiagram.model.setDataProperty(
              data,
              "vendingMachineWidth",
              data.vendingMachineWidth + 1
            );

            myDiagram.layoutDiagram(true); // force layout to redo
            // update invisibleCells' positions
            updateInvisibleCells();
            myDiagram.commitTransaction("add column");
          }).bind("visible", "", () => settings.editMode),
          button(
            "MinusLine",
            (e, btn) => {
              // subtract 1 from vendingMachineWidth
              const data = getDataFromGraphObject(btn);
              const newVendingMachineWidth = data.vendingMachineWidth - 1;
              const affectedSodas = allSodas().filter(
                (soda) =>
                  soda.group === data.key && soda.coil >= newVendingMachineWidth
              );

              myDiagram.startTransaction("show modal");
              for (const soda of affectedSodas) {
                myDiagram.model.setDataProperty(soda, "toDelete", true);
              }

              if (affectedSodas.length > 0) {
                showModal(
                  affectedSodas.length,
                  () => {
                    removeColumn(affectedSodas, data, newVendingMachineWidth);
                  },
                  btn
                );
                myDiagram.commitTransaction("show modal");
              } else {
                myDiagram.commitTransaction("show modal");
                removeColumn(affectedSodas, data, newVendingMachineWidth);
              }
            },
            // space between add and delete button
            new go.Margin(3, 0, 0, 0)
          ).bind(
            "visible",
            "vendingMachineWidth",
            (count) => count > 3 && settings.editMode
          )
        );
      }

      function spacerForWhenButtonsArentThere() {
        return new go.Shape({
          width: 11,
          opacity: 0,
        }).bind("visible", "", () => !settings.editMode);
      }

      function shelfTop() {
        return new go.Panel("Horizontal", {
          height: settings.defaultShelfHeight - 20,
          itemTemplate: new go.Panel("Horizontal", {
            height: 37,
            alignment: go.Spot.Bottom,
          }).add(
            new go.Shape({
              fill: "transparent",
              name: "COIL",
              // somewhat vertically stretched half circle
              geometryString: "M0 0 a20 20 0 0 1 40 0",
              height: 12,
              margin: new go.Margin(0, 5),
              width: 25,
              strokeWidth: 2,
              stroke: settings.colors.stroke,
              alignment: go.Spot.Bottom,
            }),
            new go.Shape("MinusLine", {
              // height means width because the angle being 90 flips it
              height: 2,
              margin: new go.Margin(0, 1.5),
              angle: 90,
              strokeWidth: 2,
              stroke: settings.colors.stroke,
            }).bind("visible", "lineVisible")
          ),
          itemArray: [{}, {}],
        })
          .bind("margin", "height", (h) => new go.Margin(h - 70, 0, 0, 0))
          .bind("itemArray", "", (data) => {
            // find group
            const nodeData = findGroupData(data);
            if (nodeData) {
              array = Array(nodeData.vendingMachineWidth)
                .fill(null)
                .map(() => ({ lineVisible: true }));
              array[nodeData.vendingMachineWidth - 1] = {
                lineVisible: false,
              };
              return array;
            } else {
              return [];
            }
          });
      }

      // I made this way more complicated than it should have to be because it wasn't arranged right otherwise
      function shelfLabels() {
        return new go.Panel("Horizontal", {
          alignment: go.Spot.Left,
          itemTemplate: new go.Panel("Horizontal", {}).add(
            new go.Panel("Auto", {}).add(
              // invisible shape to force size
              new go.Shape("Rectangle", {
                width: 36,
                opacity: 0,
              }),
              // label
              new go.Panel("Auto").add(
                new go.Shape("RoundedRectangle", {
                  height: 10,
                  width: 20,
                  fill: "white",
                  stroke: null,
                }),

                // labels (A1, A2, etc)
                new go.TextBlock({
                  // the 900 makes text thicker
                  font: "900 8px sans-serif",
                  stroke: settings.colors.exterior,
                }).bind("text", "", (data) => {
                  return data.letter + "" + data.i;
                })
              )
            ),
            new go.Shape("Rectangle", {
              // height means width because the angle being 90 flips it
              width: 6,
              opacity: 0,
            }).bind("visible", "lineVisible")
          ),
          itemArray: [{}, {}],
        })
          .bind("itemArray", "", (data) => {
            // find group
            const nodeData = findGroupData(data);
            if (nodeData) {
              const row = nodeData.itemArray.indexOf(data);
              const letter = String.fromCharCode(65 + row);

              array = Array(nodeData.vendingMachineWidth)
                .fill(null)
                .map((_, i) => ({
                  i: i + 1,
                  lineVisible: true,
                  letter: letter,
                }));
              array[nodeData.vendingMachineWidth - 1].lineVisible = false;
              return array;
            } else {
              return [];
            }
          })
          .bind("visible", "", () => !settings.editMode);
      }

      function shelfBottom() {
        return new go.Panel("Auto", {
          stretch: go.Stretch.Horizontal,
          height: 20,
        }).add(
          new go.Shape("Rectangle", {
            fill: settings.colors.exterior,
            stroke: settings.colors.stroke,
            strokeWidth: 2,
            alignment: go.Spot.Center,
            height: 18,
          }),
          // shelf + and - buttons
          new go.Panel("Horizontal")
            .add(addShelfButton(), removeShelfButton(), numberInput())
            .bind("visible", "", () => settings.editMode),
          shelfLabels()
        );
      }

      function interior() {
        return new go.Panel("Horizontal", {
          stretch: go.Stretch.Horizontal,
          column: 0,
          margin: new go.Margin(20, 5),
        }).add(
          leftAddAndDeleteButtons(),
          spacerForWhenButtonsArentThere(),
          // WINDOW
          new go.Panel("Auto", {
            name: "HIGHLIGHT GOES HERE",
          }).add(
            // window background
            new go.Shape("RoundedRectangle", {
              name: "WINDOW",
              fill: settings.colors.interior,
              stroke: null,
              strokeWidth: 2,
            }),
            // shelves
            new go.Panel("Vertical", {
              name: "SHELVESLIST",
              itemTemplate: new go.Panel("Vertical", {
                margin: new go.Margin(5, 0),
                opacity: 0.8,
              })
                .bind("height", "height")
                .add(shelfTop(), shelfBottom()),
            }).bind("itemArray")
          )
        );
      }

      function controls() {
        const r2 = settings.r - 5;
        return new go.Panel("Auto", {
          column: 1,
          width: 70,
          height: 180,
          margin: new go.Margin(0, 10),
        }).add(
          // dark background
          new go.Shape("RoundedRectangle", {
            stroke: settings.colors.stroke,
            strokeWidth: 2,
            fill: settings.colors.dark,
          }),
          new go.Panel("Vertical", {}).add(
            // screen
            new go.Panel("Auto").add(
              new go.Shape("RoundedRectangle", {
                stroke: settings.colors.stroke,
                strokeWidth: 2,
                fill: settings.colors.keypad,
                height: 45,
                width: 50,
                margin: new go.Margin(2),
              }),
              // highlight for aesthetics
              makeCornerDecoration({
                alignment: "TopLeft",
                margin: [3],
                r: r2,
              })
            ),
            // arrow
            new go.Shape("TriangleDown", {
              stroke: null,
              strokeWidth: 2,
              fill: "#e9cf86",
              width: 15,
              height: 5,
              alignment: go.Spot.Center,
              margin: new go.Margin(2),
            }),
            // card slot
            new go.Panel("Auto", {
              margin: new go.Margin(2),
              height: 10,
              width: 50,
            }).add(
              new go.Shape("RoundedRectangle", {
                stroke: settings.colors.stroke,
                strokeWidth: 2,
                fill: settings.colors.keypad,
              }),
              new go.Shape("MinusLine", {
                width: 30,
                stroke: settings.colors.dark,
                strokeWidth: 2,
                fill: settings.colors.keypad,
              })
            ),
            // keypad
            new go.Panel("Auto").add(
              new go.Shape("RoundedRectangle", {
                stroke: settings.colors.stroke,
                strokeWidth: 2,
                fill: settings.colors.keypad,
                height: 70,
                width: 50,
                margin: new go.Margin(2),
              }),
              makeNumberButtons()
            )
          )
        );
      }

      function vendingFeet() {
        return new go.Panel("Table", {
          stretch: go.Stretch.Horizontal,
          height: 20,
        }).add(
          new go.Shape("RoundedBottomRectangle", {
            column: 0,
            width: 30,
            height: 18,
            stroke: settings.colors.stroke,
            strokeWidth: 2,
            fill: settings.colors.exterior,
            alignment: go.Spot.Left,
          }),
          new go.Shape("RoundedBottomRectangle", {
            column: 1,
            width: 30,
            height: 18,
            stroke: settings.colors.stroke,
            strokeWidth: 2,
            fill: settings.colors.exterior,
            alignment: go.Spot.Right,
          })
        );
      }

      function vendingTemplate() {
        return (
          new go.Group("Vertical", {
            location: new go.Point(0, 0),
            locationSpot: go.Spot.BottomLeft,
            mouseDrop: handleVendingMouseDrop,
            zOrder: 0,
          })
            .bind("zOrder", "zOrder", (zOrder) => zOrder * 4)
            /* each group has a block of 4 z-orders for nodes related to the group
             * +0: vending machine
             * +1: invisible cells
             * +2: soda
             * +3: highlight
             */

            .add(
              new go.Panel("Auto", {}).add(
                new go.Shape("RoundedTopRectangle", {
                  fill: settings.colors.exterior,
                  strokeWidth: 2,
                  stroke: settings.colors.stroke,
                  parameter1: settings.r,
                }),
                new go.Panel("Table", {
                  stretch: go.Stretch.Fill,
                }).add(
                  // main part of vending machine
                  new go.Panel("Table", {
                    row: 0,
                    stretch: go.Stretch.Fill,
                  }).add(interior(), controls()),
                  // dividing line
                  new go.Shape("MinusLine", {
                    row: 1,
                    height: 5,
                    stretch: go.Stretch.Horizontal,
                    strokeWidth: 2,
                    stroke: settings.colors.stroke,
                  }),
                  // pick up box
                  new go.Panel("Auto", {
                    row: 2,
                    margin: new go.Margin(20),
                    height: 50,
                    stretch: go.Stretch.Horizontal,
                  }).add(
                    new go.Shape("RoundedRectangle", {
                      fill: settings.colors.dark,
                      strokeWidth: 2,
                      stroke: settings.colors.stroke,
                    }),
                    // highlights for aesthetics
                    new go.Shape({
                      alignment: go.Spot.TopLeft,
                      geometryString: `M0 0 l5 0 m-20 0 l-50 0 a${settings.r} ${
                        settings.r
                      } 0 0 0 ${-settings.r} ${settings.r} l0 20`,
                      margin: new go.Margin(3),
                      strokeCap: "round",
                      stroke: settings.colors.highlight,
                      strokeWidth: 7,
                      opacity: 0.2,
                    })
                  )
                ),
                // highlights for aesthetics
                makeCornerDecoration({
                  alignment: "TopLeft",
                  r: settings.r,
                  height: 50,
                  width: 50,
                  margin: [3],
                  horizontalDotWidth: 5,
                  horizontalDotSpacing: 20,
                  strokeWidth: 10,
                }),
                // highlights for aesthetics
                makeCornerDecoration({
                  alignment: "TopRight",
                  r: settings.r,
                  height: 50,
                  width: 50,
                  margin: [3],
                  verticalDotWidth: 10,
                  verticalDotSpacing: 20,
                  strokeWidth: 10,
                }),
                // shadows for aesthetics
                new go.Shape({
                  alignment: go.Spot.BottomRight,
                  margin: new go.Margin(6, 2),
                  strokeCap: "round",
                  stroke: settings.colors.shadow,
                  strokeWidth: 10,
                  opacity: 0.4,
                }).bind(
                  "geometryString",
                  "vendingMachineWidth",
                  (vendingMachineWidth) =>
                    `M0 0 l0 50 a${settings.r} ${
                      settings.r
                    } 0 0 1 ${-settings.r} ${settings.r} l${
                      -45 * vendingMachineWidth
                    } 0`
                )
              ),
              vendingFeet()
            )
            .bindTwoWay(
              "location",
              "position",
              go.Point.parse,
              go.Point.stringify
            )
        );
      }

      function vendingPaletteTemplate() {
        return new go.Group("Auto").add(
          new go.Shape("RoundedRectangle", {
            fill: settings.colors.exterior,
            strokeWidth: 2,
            stroke: settings.colors.stroke,
          }),
          new go.Panel("Vertical").add(
            new go.Panel("Horizontal").add(
              new go.Shape("RoundedRectangle", {
                fill: settings.colors.interior,
                strokeWidth: 2,
                stroke: settings.colors.stroke,
                height: 100,
                width: 80,
                margin: 2,
              }),
              new go.Shape("RoundedRectangle", {
                fill: settings.colors.dark,
                strokeWidth: 2,
                stroke: settings.colors.stroke,
                width: 30,
                height: 50,
                margin: 2,
              })
            ),
            new go.Shape("MinusLine", {
              height: 2,
              strokeWidth: 2,
              stroke: settings.colors.stroke,
              margin: new go.Margin(2, 0),
              width: 110,
            }),
            new go.Shape("RoundedRectangle", {
              fill: settings.colors.dark,
              strokeWidth: 2,
              stroke: settings.colors.stroke,
              height: 20,
              margin: 2,
            })
          )
        );
      }

      function highlightTemplate() {
        return new go.Node("Auto", {
          pickable: false,
        })
          .add(
            makeCornerDecoration({
              width: 50,
              height: 50,
              horizontalDotSpacing: 20,
              horizontalDotWidth: 5,
              r: settings.r,
              strokeWidth: 10,
              opacity: 0.7,
              margin: [6],
            })
          )
          .bind("zOrder", "", (data) => {
            const groupData = myDiagram.model.findNodeDataForKey(data.group);
            const zOrder = groupData.zOrder;
            return zOrder * 4 + 3;

            /* each group has a block of 4 z-orders for nodes related to the group
             * +0: vending machine
             * +1: invisible cells
             * +2: soda
             * +3: highlight
             */
          });
      }

      function invisibleCellTemplate() {
        return new go.Node("Auto", {
          pickable: false,
          locationSpot: go.Spot.BottomCenter,
          selectable: false,
          zOrder: 1,
          mouseDrop: (e, node) => {
            // if you drop an item on an invisible cell, it just runs the mouseDrop function for the vending machine it's in
            // that way the invisible cells don't get in the way
            node.containingGroup.mouseDrop(e, node.containingGroup);
          },
        })
          .bind("zOrder", "", (data) => {
            const groupData = myDiagram.model.findNodeDataForKey(data.group);
            const zOrder = groupData.zOrder;
            return zOrder * 4 + 1;

            /* each group has a block of 4 z-orders for nodes related to the group
             * +0: vending machine
             * +1: invisible cells
             * +2: soda
             * +3: highlight
             */
          })
          .add(
            new go.Shape("Rectangle", {
              name: "SHAPE",
              fill: "transparent",
              stroke: null,
              opacity: 0.25,
            })
              .bindTwoWay("height")
              .bindTwoWay("width")
          )
          .bindTwoWay(
            "location",
            "position",
            go.Point.parse,
            go.Point.stringify
          );
      }

      // =================== SODA TEMPLATES ===================

      const sodaCanTemplate = new go.Node("Auto", {})
        .add(
          new go.Panel("Auto").add(
            // main can body
            new go.Shape("RoundedRectangle", {
              name: "MAIN_SHAPE",
              height: 50,
              parameter1: 4,
            }),

            // label
            new go.Panel("Auto", {
              width: 30,
              height: 20,
              alignment: go.Spot.Center,
            }).add(
              new go.Shape("RoundedRectangle", {
                name: "LABEL_SHAPE",
                parameter1: 3,
              }),
              new go.TextBlock("SODA", {
                name: "LABEL_TEXT",
                font: "900 8px sans-serif",
              })
            )
          ),

          // duplicate indicator outline (hidden unless duplicate)
          // also used (when it's blue) to show which items you're about to delete
          new go.Shape("Rectangle", {
            name: "DUPLICATE_OUTLINE",
            height: 50,
          }),
          new go.Shape("RoundedRectangle", {
            name: "HIGHLIGHT",
            height: 48,
            margin: new go.Margin(0, 0, 0, 2),
          })
        )
        .apply(sodaStyle);

      const sodaBottleTemplate = new go.Node("Auto", {})
        .add(
          new go.Panel("Spot").add(
            new go.Shape("Soda", {
              name: "MAIN_SHAPE",
              height: 75,
            }),
            // label
            new go.Panel("Auto", {
              // adding strokeWidth to width so they align
              height: 25,
              width: 37,
              alignment: new go.Spot(0, 0.4),
              alignmentFocus: go.Spot.TopLeft,
            }).add(
              new go.Shape("Rectangle", {
                name: "LABEL_SHAPE",
              }),
              new go.TextBlock("SODA", {
                name: "LABEL_TEXT",
                font: "900 10px sans-serif",
              })
            )
          ),
          // highlight
          new go.Shape("RoundedRectangle", {
            name: "HIGHLIGHT",
            height: 55,
            margin: new go.Margin(15, 0, 0, 2),
          }),
          // duplicate indicator outline (hidden unless duplicate)
          // also used (when it's blue) to show which items you're about to delete
          new go.Shape("Rectangle", {
            name: "DUPLICATE_OUTLINE",
            height: 75,
          })
        )
        .apply(sodaStyle);

      // =================== MODAL TEMPLATES ===================

      // transparent shape that will fill the whole screen so the user can't do anything until they answer the pop up
      function modalBackgroundTemplate() {
        return new go.Node("Auto", {
          layerName: "Foreground",
          locationSpot: go.Spot.Center,
          movable: false,
          zOrder: 1, // goes behind modal which has z order 2
        })
          .add(
            new go.Shape({
              fill: settings.colors.interior,
              opacity: 0.2,
            })
              .bind("height", "viewportHeight")
              .bind("width", "viewportWidth")
          )
          .bind("visible");
      }

      function modalButton(text, margin, name) {
        const btn = go.GraphObject.build("Button", {
          "ButtonBorder.strokeWidth": 0,
          "ButtonBorder.fill": settings.colors.palettes.beige.labelColor,
          click: () => {
            hideModal();
          },
        }).add(
          new go.TextBlock(text, {
            stroke: settings.colors.stroke,
          })
        );
        if (margin) {
          btn.margin = margin;
        }
        if (name) {
          btn.name = name;
        }
        return btn;
      }

      function modalTemplate() {
        return new go.Node("Auto", {
          layerName: "Foreground",
          zOrder: 2, // goes in front of bg which has z order 1
          locationSpot: go.Spot.TopLeft,
          selectionAdorned: false,
        })
          .add(
            new go.Shape("RoundedRectangle", {
              fill: settings.colors.palettes.beige.fillColor,
              strokeWidth: 2,
              stroke: settings.colors.stroke,
            }),
            new go.Panel("Vertical", {
              margin: 10,
            }).add(
              // "This will delete x items. Are you sure?"
              new go.TextBlock({
                name: "TEXT",
                font: "15px sans-serif",
                margin: new go.Margin(0, 0, 5, 0),
                stroke: settings.colors.stroke,
              }).bind("text"),
              new go.Panel("Horizontal").add(
                modalButton("Yes", new go.Margin(0, 5, 0, 0), "YES"),
                modalButton("No")
              )
            )
          )
          .bind("visible");
      }

      /***********************************************************************
       *                        HELPFUL FUNCTIONS                            *
       ***********************************************************************/

      // returns a list of all soda data in diagram model
      function allSodas() {
        return myDiagram.model.nodeDataArray.filter((node) =>
          sodaCategories.includes(node.category)
        );
      }

      // returns a list of all group NODES in diagram model
      function allGroups() {
        const groups = [];
        myDiagram.nodes.each((node) => {
          if (node instanceof go.Group) {
            groups.push(node);
          }
        });
        return groups;
      }

      // return data of soda at given spot
      // or returns false if there's nothing at that location
      // used in getClosestCell()
      function sodaAt(group, shelf, coil) {
        for (const soda of allSodas()) {
          if (
            soda.group === group &&
            soda.shelf === shelf &&
            soda.coil === coil
          ) {
            return soda;
          }
        }
        return false;
      }

      // runs the filterFunction on all sodas and then runs the actionFunction on each soda that passes the filter
      function editSoda(filterFunction, actionFunction) {
        const filteredSodas = allSodas().filter(filterFunction);
        for (const nodeData of filteredSodas) {
          actionFunction(nodeData);
        }
      }

      function highestZOrder() {
        let maxZOrder = 0;
        myDiagram.model.nodeDataArray.forEach((data) => {
          if (data.isGroup && data.zOrder && data.zOrder > maxZOrder) {
            maxZOrder = data.zOrder;
          }
        });
        return maxZOrder;
      }

      // used in removeShelfButton()
      function getButtonClickedInfo(btn) {
        // find first panel with data
        let dataPanel = btn.panel;
        while (!dataPanel.data) {
          dataPanel = dataPanel.panel;
        }

        // return info about it
        const { itemArray, key } = btn.part.data;
        const data = dataPanel.data;
        return {
          itemArray: itemArray,
          groupKey: key,
          shelfNum: itemArray.indexOf(data),
        };
      }

      // give an array in the itemArray of the group and it returns the data of the surrounding group
      function findGroupData(data) {
        function findGroupDataInModel(diagram) {
          return diagram.model.nodeDataArray.find(
            (nodeData) => nodeData.isGroup && nodeData.itemArray.includes(data)
          );
        }

        let result = findGroupDataInModel(myDiagram);
        if (!result) {
          result = findGroupDataInModel(vendingMachinePalette);
        }
        return result;
      }

      function getDataFromGraphObject(button) {
        let panel = button.panel;
        while (!panel.data) {
          panel = panel.panel;
        }
        return panel.data;
      }

      function topMostGroupKey(location) {
        // get top most-group
        const topGroup = myDiagram.findObjectAt(
          location,
          (obj) => obj.part,
          (obj) => obj.part instanceof go.Group
        );
        return topGroup ? topGroup.data.key : null;
      }

      // helper function for getClosestCell()
      function findIntersectingCells(node, groupKey) {
        const nodeBounds = node.getDocumentBounds();
        const intersectingCells = [];
        const invisibleCells = myDiagram.findNodesByExample({
          category: "InvisibleCell",
          group: groupKey,
        });
        invisibleCells.each((cell) => {
          const cellBounds = cell.getDocumentBounds();
          // if node is touching cell
          if (nodeBounds.intersectsRect(cellBounds)) {
            // if node is the right size to fit in cell
            if (
              nodeBounds.height <= cellBounds.height &&
              nodeBounds.width <= cellBounds.width
            ) {
              const { group, shelf, coil } = cell.data;
              // now as long as there's no soda already there, add it to the list
              const sodaAlreadyThere = sodaAt(group, shelf, coil);
              if (sodaAlreadyThere.key === node.data.key || !sodaAlreadyThere) {
                intersectingCells.push(cell);
              }
            }
          }
        });
        return intersectingCells;
      }

      // helper function for getClosestCell()
      function cellClosestToMouse(cells) {
        const mousePt = myDiagram.lastInput.documentPoint;
        closestDist = Infinity;
        closestCell = null;
        for (const cell of cells) {
          const bounds = cell.getDocumentBounds();
          const bottomLeft = new go.Point(bounds.x, bounds.y + bounds.height);
          const dist = mousePt.distanceSquaredPoint(bottomLeft);
          if (dist < closestDist) {
            closestDist = dist;
            closestCell = cell;
          }
        }
        return closestCell;
      }

      // give a node (and a groupKey, if you already know the topmost group it should be in)
      // returns the closest cell that the node can fit into.
      // (or null if the node isn't touching any cells)
      function getClosestCell(node, groupKey) {
        if (!groupKey) {
          groupKey = topMostGroupKey(node.location);
          if (!groupKey) return null;
        }

        const intersectingCells = findIntersectingCells(node, groupKey);

        if (intersectingCells.length > 1) {
          return cellClosestToMouse(intersectingCells);
        } else if (intersectingCells.length === 1) {
          return intersectingCells[0];
        } else {
          return null;
        }
      }

      // =================== DEAL WITH DUPLICATE SODAS ===================

      // Marks in model data whether for all sodas of this type, whether they are duplicates or not
      function handleDuplicates(type) {
        const sodas = allSodas().filter(
          (soda) => soda.color + soda.category === type
        );
        const isDuplicate = sodas.length > 1;
        for (const soda of sodas) {
          myDiagram.model.setDataProperty(soda, "duplicate", isDuplicate);
        }
        if (!settings.allowDuplicates) findAndRemovePaletteDuplicates();
      }

      function handleAllDuplicates() {
        const types = [];
        for (soda of allSodas()) {
          const type = soda.color + soda.category;
          if (!types.includes(type)) {
            types.push(type);
          }
        }
        for (type of types) {
          handleDuplicates(type);
        }
        if (!settings.allowDuplicates) findAndRemovePaletteDuplicates();
      }

      // finds all duplicate sodas in the palette and removes them
      // but only if allowDuplicates is false
      // regardless of whether it's false or not, if it's from the palette we set it as not from the palette anymore
      function handleItemFromPalette(data) {
        myDiagram.model.setDataProperty(data, "isFromPalette", undefined);
        myDiagram.model.setDataProperty(data, "paletteLocation", undefined);
        if (!settings.allowDuplicates) {
          palette.model.removeNodeData(
            palette.model.findNodeDataForKey(data.type)
          );
        }
        handleDuplicates(data.type);
        if (!settings.allowDuplicates) findAndRemovePaletteDuplicates();
      }

      // =================== FUNCTIONS TO EDIT THE MODEL ===================

      // vending machine falling animation when you drop it somewhere
      function animateDrop(nodes) {
        myDiagram.startTransaction("drop vending machine");
        const animation = new go.Animation();
        animation.duration = 400;
        nodes.each((selected) => {
          if (selected instanceof go.Group) {
            animation.add(
              selected,
              "location",
              selected.location,
              new go.Point(selected.location.x, 0)
            );
            // make it animate all items in the group too
            selected.memberParts.each((member) => {
              animation.add(
                member,
                "location",
                member.location,
                new go.Point(
                  member.location.x,
                  member.location.y - selected.location.y
                )
              );
            });
            const highlight = myDiagram.findNodeForKey(
              "highlight " + selected.key
            );
            if (highlight) {
              animation.add(
                highlight,
                "location",
                highlight.location,
                new go.Point(
                  highlight.location.x,
                  highlight.location.y - selected.location.y
                )
              );
            }
            setTimeout(() => {
              myDiagram.model.setDataProperty(
                selected.data,
                "position",
                selected.location.x + " 0"
              );
            }, animation.duration);
          }
        });
        animation.start();

        setTimeout(() => {
          remakeAllInvisibleCells();
          updateHighlights();
          myDiagram.commitTransaction("drop vending machine");
        }, animation.duration);
      }

      function fillShelf() {
        const fillSoda = sodaRightClickedData;
        myDiagram.startTransaction("fill shelf");
        const shelfNum = fillSoda.shelf;
        const groupKey = fillSoda.group;
        const vendingMachineWidth =
          myDiagram.model.findNodeDataForKey(groupKey).vendingMachineWidth;

        // delete former sodas
        editSoda(
          (soda) =>
            soda.shelf === shelfNum &&
            soda.group === groupKey &&
            soda !== fillSoda,
          (soda) => {
            myDiagram.model.removeNodeData(soda);
          }
        );

        // fill with new sodas
        for (let i = 0; i < vendingMachineWidth; i++) {
          if (i !== fillSoda.coil) {
            const data = Object.assign({}, fillSoda);
            data.coil = i;
            myDiagram.model.addNodeData(data);
          }
        }
        handleDuplicates(fillSoda.color + fillSoda.category);
        myDiagram.commitTransaction("fill shelf");
      }

      function removeShelf(affectedSodas, shelfNum, itemArray, groupKey) {
        myDiagram.startTransaction("Delete row");
        for (const soda of affectedSodas) {
          if (soda.shelf === shelfNum) {
            const type = soda.type;
            myDiagram.model.removeNodeData(soda);
            handleDuplicates(type);
            if (!settings.allowDuplicates) findAndRemovePaletteDuplicates();
          } else {
            myDiagram.model.setDataProperty(soda, "shelf", soda.shelf - 1);
          }
        }

        handleAllDuplicates();
        myDiagram.model.removeArrayItem(itemArray, shelfNum);
        // we need this to update locations before transaction is finished
        myDiagram.findNodeForKey(groupKey).ensureBounds();
        updateInvisibleCells();
        updateHighlights();
        myDiagram.updateAllTargetBindings();
        myDiagram.commitTransaction("Delete row");
      }

      function removeColumn(affectedSodas, data, newVendingMachineWidth) {
        myDiagram.startTransaction("delete column");
        myDiagram.model.setDataProperty(
          data,
          "vendingMachineWidth",
          newVendingMachineWidth
        );

        // get rid of items that were on deleted shelf
        for (const soda of affectedSodas) {
          myDiagram.model.removeNodeData(soda);
        }

        handleAllDuplicates();

        // update invisibleCells' positions
        updateInvisibleCells();
        myDiagram.commitTransaction("delete column");
      }

      // =================== MODAL ===================

      function makeModal() {
        myDiagram.model.addNodeData({
          key: "modal",
          category: "modal",
          visible: false,
        });
        myDiagram.model.addNodeData({
          key: "modalBg",
          category: "modalBg",
          visible: false,
        });
      }

      function showModal(sodaDeleteCount, deleteFunction, button) {
        let modalData = myDiagram.model.findNodeDataForKey("modal");
        if (!modalData) {
          makeModal();
          modalData = myDiagram.model.findNodeDataForKey("modal");
        }
        modalNode = myDiagram.findNodeForKey("modal");

        let modalBgData = myDiagram.model.findNodeDataForKey("modalBg");
        modalBgNode = myDiagram.findNodeForKey("modalBg");

        const center = myDiagram.viewportBounds.center;
        myDiagram.model.setDataProperty(modalData, "visible", true);
        myDiagram.model.setDataProperty(modalBgData, "visible", true);
        myDiagram.model.setDataProperty(
          modalBgData,
          "viewportHeight",
          myDiagram.viewportBounds.height
        );
        myDiagram.model.setDataProperty(
          modalBgData,
          "viewportWidth",
          myDiagram.viewportBounds.width
        );
        myDiagram.model.setDataProperty(
          modalData,
          "text",
          `This will delete ${sodaDeleteCount} ${
            sodaDeleteCount === 1 ? "item" : "items"
          }. Are you sure?`
        );

        // position modal so it doesn't go off screen
        modalNode.updateTargetBindings();
        modalNode.ensureBounds(); // <--- force GoJS to measure now
        const modalBounds = modalNode.measuredBounds;
        const { width, height } = modalBounds;
        modalNode.location = button.getDocumentBounds().center;
        const rightEdgeX =
          myDiagram.viewportBounds.center.x +
          myDiagram.viewportBounds.width / 2;
        const bottomEdgeY =
          myDiagram.viewportBounds.center.y +
          myDiagram.viewportBounds.height / 2;
        const modalRightEdge = modalNode.location.x + modalBounds.width;
        if (modalRightEdge > rightEdgeX) {
          modalNode.location = new go.Point(
            modalNode.location.x - (modalRightEdge - rightEdgeX),
            modalNode.location.y
          );
        }
        const modalBottomEdge = modalNode.location.y + modalBounds.height;
        if (modalBottomEdge > bottomEdgeY) {
          modalNode.location = new go.Point(
            modalNode.location.x,
            modalNode.location.y - (modalBottomEdge - bottomEdgeY)
          );
        }

        modalBgNode.location = center;
        modalNode.findObject("YES").click = () => {
          deleteFunction();
          hideModal();
        };
        myDiagram.select(modalNode);
      }

      function hideModal() {
        myDiagram.startTransaction("hide modal");

        // get rid of all the highlights on the toDelete sodas
        editSoda(
          (soda) => soda.toDelete,
          (soda) => {
            myDiagram.model.setDataProperty(soda, "toDelete", false);
          }
        );

        const modal = myDiagram.model.findNodeDataForKey("modal");
        const modalBg = myDiagram.model.findNodeDataForKey("modalBg");

        myDiagram.model.setDataProperty(modal, "visible", false);
        myDiagram.model.setDataProperty(modalBg, "visible", false);
        myDiagram.commitTransaction("hide modal");
      }

      /***********************************************************************
       *                    DIAGRAM SETUP/EDIT FUNCTIONS                     *
       ***********************************************************************/

      function initPalette() {
        palette.model = new go.GraphLinksModel([]);
        let x = 0;
        let y = 0;
        for (const key in settings.colors.palettes) {
          x = 0;
          for (const style of ["can", "bottle"]) {
            palette.model.addNodeData({
              key: key + style,
              type: key + style,
              category: style,
              isFromPalette: true,
              paletteLocation: x + " " + y,
              color: key,
            });
            x += settings.paletteXSpacing;
          }
          y += settings.paletteYSpacing;
        }
      }

      function findAndRemovePaletteDuplicates() {
        const typesInDiagram = new Set();
        for (soda of allSodas()) {
          const type = soda.color + soda.category;
          typesInDiagram.add(type);
        }
        const allPossibleTypes = new Set();
        for (key in settings.colors.palettes) {
          allPossibleTypes.add(key + "can");
          allPossibleTypes.add(key + "bottle");
        }
        const typesInPalette = allPossibleTypes.difference(typesInDiagram);
        allPossibleTypes.forEach((type) => {
          const paletteSoda = palette.model.findNodeDataForKey(type);
          if (paletteSoda && !typesInPalette.has(type)) {
            palette.model.removeNodeData(paletteSoda);
          }
          if (!paletteSoda && typesInPalette.has(type)) {
            const match = type.match(/(.*)(bottle|can)/);
            const color = match[1];
            const canOrBottle = match[2];
            const x = ["can", "bottle"].indexOf(canOrBottle);
            const y = Object.keys(settings.colors.palettes).indexOf(color);
            palette.model.addNodeData({
              key: color + canOrBottle,
              category: canOrBottle,
              color: color,
              isFromPalette: true,
              paletteLocation:
                x * settings.paletteXSpacing +
                " " +
                y * settings.paletteYSpacing,
            });
          }
        });
      }

      /***********************************************************************
       *                          EVENT FUNCTIONS                            *
       ***********************************************************************/

      function handleDiagramMouseDrop(e) {
        resetAllInvisibleCells();
        if (settings.allowTopLevel) {
          // when the selection is dropped in the diagram's background,
          // make sure the selected Parts no longer belong to any Group
          if (
            !e.diagram.commandHandler.addTopLevelParts(
              e.diagram.selection,
              true
            )
          ) {
            e.diagram.currentTool.doCancel();
          }
        } else {
          // disallow dropping any regular nodes onto the background, but allow dropping vending machines,
          // including any selected member nodes
          if (
            !e.diagram.selection.all((p) => {
              return (
                p instanceof go.Group ||
                (!p.isTopLevel && p.containingGroup.isSelected)
              );
            })
          ) {
            e.diagram.currentTool.doCancel();
          }
        }

        // if you drop a vending machine somewhere it'll animate it going back to y=0
        // you can just give the animateDrop function all the dragged parts
        // it skips non-vending-machines
        let draggingParts = e.diagram.currentTool.draggingParts;
        if (draggingParts) {
          animateDrop(draggingParts);
        }
      }

      // handles dropping a vending machine into the diagram from the palette
      function handleExternalObjectDropped(node) {
        if (node instanceof go.Group) {
          // Remakes itemArray on drop
          // Otherwise, all vending machines from palette share the reference to the same one
          // So adding a shelf to one affects all the others (that are from the palette)
          // Fix: remake it so each machine gets its own new itemArray
          node.data.itemArray = Array(node.data.itemArray.length)
            .fill(null)
            .map(() => ({}));

          myDiagram.model.setDataProperty(
            node.data,
            "zOrder",
            highestZOrder() + 1
          );
          updateHighlights();
          animateDrop(new go.Set().add(node));
        }
        if (node.data.isFromPalette) {
          handleItemFromPalette(node.data);
        }
      }

      /***********************************************************************
       *                    INVISIBLE CELLS FUNCTIONS                        *
       ***********************************************************************/

      function populateWithInvisibleCells() {
        for (grp of allGroups()) {
          let shelfnum = 0;
          const shelves = grp.findObject("SHELVESLIST");
          let prevMarginBottom = 0;
          shelves.elements.each((verticalShelf) => {
            // whole shelf
            const coils = verticalShelf.elt(0);
            const shelfHeight =
              coils.height +
              coils.margin.top +
              verticalShelf.margin.top +
              prevMarginBottom;
            prevMarginBottom = verticalShelf.margin.bottom;
            let coilnum = 0;
            for (const panel of coils.elements) {
              // found a coil
              coil = panel.elements.first();
              if (coil.name === "COIL") {
                // found a coil
                const bounds = coil.getDocumentBounds();
                // bottom center of coil
                // i just add 10 for the margin, then an extra 4 to the width to make it fill up the whole space. since the divider lines have a margin of 1.5 on each side so you should add 3 but 4 makes it look nicer
                const coilWidth = bounds.width + 14;
                myDiagram.model.addNodeData({
                  // IVC for In-Visible-Cell
                  key: "IVC " + grp.key + " " + shelfnum + " " + coilnum,
                  shelf: shelfnum,
                  coil: coilnum,
                  group: grp.data.key,
                  category: "InvisibleCell",
                  position:
                    bounds.x +
                    bounds.width / 2 +
                    " " +
                    (bounds.y + bounds.height),
                  width: coilWidth,
                  height: shelfHeight,
                });
                coilnum++;
              }
            }
            shelfnum++;
          });
        }
      }

      function updateHighlights() {
        for (grp of allGroups()) {
          let highlight = myDiagram.findNodeForKey("highlight " + grp.key);
          if (!highlight) {
            myDiagram.model.addNodeData({
              key: "highlight " + grp.key,
              category: "highlight",
              group: grp.key,
            });
            highlight = myDiagram.findNodeForKey("highlight " + grp.key);
          }
          const loc = grp.findObject("HIGHLIGHT GOES HERE").getDocumentBounds();
          highlight.location = new go.Point(loc.x, loc.y);
        }
      }

      function resetAllInvisibleCells() {
        myDiagram.nodes.each((node) => {
          if (node.data.category === "InvisibleCell") {
            node.findObject("SHAPE").fill = "transparent";
          }
        });
      }

      function updateInvisibleCells() {
        const allCells = myDiagram.model.nodeDataArray.filter(
          (data) => data.category === "InvisibleCell"
        );
        for (cell of allCells) {
          myDiagram.model.removeNodeData(cell);
        }
        populateWithInvisibleCells();
        // btw this function won't make a visible change unless you do myDiagram.updateAllTargetBindings(); after calling it
      }

      function remakeAllInvisibleCells() {
        // definitely not efficient
        myDiagram.nodes.each((node) => {
          if (node.data.category === "InvisibleCell") {
            myDiagram.model.removeNodeData(node.data);
          }
        });
        populateWithInvisibleCells();
      }

      /***********************************************************************
       *                                  INIT                               *
       ***********************************************************************/
      function init() {
        // just making sure the checkboxes shows the right thing
        document.getElementById("showHideButtonsCheckbox").checked =
          settings.editMode;
        document.getElementById("settings.allowDuplicatesCheckbox").checked =
          settings.allowDuplicates;

        myDiagram = new go.Diagram(
          "myDiagramDiv", // create a Diagram for the HTML Div element
          {
            "undoManager.isEnabled": true,
            "animationManager.initialAnimationStyle": go.AnimationStyle.None,
          }
        );

        // =================== OVERRIDING TOOLS ===================

        // this makes it so when you drag a soda it will highlight where it can drop
        myDiagram.toolManager.draggingTool.doDragOver = function () {
          // calling super function
          go.DraggingTool.prototype.doDragOver.call(this);

          resetAllInvisibleCells();

          let draggingGroup = false;
          this.draggingParts.each((selected) => {
            if (draggingGroup) return;

            const topGroup = topMostGroupKey(selected.location);
            if (!topGroup) {
              if (!settings.allowTopLevel) {
                myDiagram.currentCursor = "not-allowed";
              } else {
                myDiagram.currentCursor = "";
              }
              return;
            }

            if (
              selected.data.isGroup ||
              selected.data.category === "InvisibleCell"
            ) {
              draggingGroup = true;
              myDiagram.currentCursor = "";
              resetAllInvisibleCells();
              updateHighlights();
              return;
            }
            const closestCell = getClosestCell(selected, topGroup);
            if (closestCell === null) {
              myDiagram.currentCursor = "not-allowed";
            } else {
              closestCell.findObject("SHAPE").fill = settings.colors.stroke;
            }
          });
        };

        myDiagram.toolManager.draggingTool.doActivate = function () {
          go.DraggingTool.prototype.doActivate.call(this);
          this._lastDraggedParts = this.draggingParts;
          this.draggingParts.each((selected) => {
            if (selected.data.isGroup) {
              const z = highestZOrder();
              if (z !== selected.data.zOrder) {
                // if the group is not the highest zOrder, set it to the highest zOrder + 1
                myDiagram.model.setDataProperty(selected.data, "zOrder", z + 1);
              }
            }

            myDiagram.updateAllTargetBindings();
          });
        };

        // when you delete an item it updates if there are still duplicates - so the red highlight will go away if you deleted a duplicate item
        myDiagram.commandHandler.deleteSelection = function () {
          myDiagram.startTransaction("delete");
          myDiagram.selection.each((node) => {
            if (node instanceof go.Group) {
              myDiagram.model.removeNodeData(
                myDiagram.model.findNodeDataForKey("highlight " + node.key)
              );
            }
          });
          go.CommandHandler.prototype.deleteSelection.call(this);
          handleAllDuplicates();
          myDiagram.updateAllTargetBindings();
          myDiagram.commitTransaction("delete");
        };

        // Set the HTMLInfo:
        myDiagram.toolManager.textEditingTool.defaultTextEditor = customEditor;

        // =================== TEMPLATES ===================

        myDiagram.groupTemplate = vendingTemplate();
        myDiagram.nodeTemplateMap.add("can", sodaCanTemplate);
        myDiagram.nodeTemplateMap.add("bottle", sodaBottleTemplate);
        myDiagram.nodeTemplateMap.add("modal", modalTemplate());
        myDiagram.nodeTemplateMap.add("modalBg", modalBackgroundTemplate());
        myDiagram.nodeTemplateMap.add("highlight", highlightTemplate());

        myDiagram.model = modelTemplate;

        /***********************************************************************
         * INVISIBLE CELL NODES – used only for drop logic
         *
         * These are invisible "helper" nodes positioned over the vending
         * machine coils. They don't show up in the diagram (fill: transparent)
         * Except when you are dragging a soda over them -
         * - then they will highlight to show where it will drop.
         * They're not saved to the JSON when you click the Save button.
         *
         * Why we use them:
         * - They define valid drop zones for soda items
         * - They let us highlight valid slots when dragging sodas
         * - They have position/size info to help calculate placement
         *
         * Each InvisibleCell is generated dynamically by
         * populateWithInvisibleCells(), based on current shelves.
         ***********************************************************************/
        myDiagram.nodeTemplateMap.add("InvisibleCell", invisibleCellTemplate());

        myDiagram.addDiagramListener("InitialLayoutCompleted", () => {
          populateWithInvisibleCells();
          updateHighlights();
          myDiagram.updateAllTargetBindings();
        });

        // otherwise the cells go back to being highlighted when you undo
        myDiagram.addModelChangedListener((e) => {
          if (e.propertyName === "FinishedUndo") {
            resetAllInvisibleCells();
          }
        });

        palette = new go.Palette("sodaPalette", {
          // share the templates with the main Diagram
          nodeTemplateMap: myDiagram.nodeTemplateMap,
        });
        // without this not-a-layout, sodas appear in the order they were added instead of the order I put them in
        class NotALayout extends go.Layout {
          // does nothing, I just don't want a layout
        }
        palette.layout = new NotALayout();
        initPalette();

        // initialize the Palette
        vendingMachinePalette = new go.Palette("vendingMachinePaletteDiv", {});
        vendingMachinePalette.groupTemplate = vendingPaletteTemplate();
        vendingMachinePalette.model = new go.GraphLinksModel([
          {
            key: 3,
            isGroup: true,
            vendingMachineWidth: 4,
            // five rows when you pull the vending machine out of the palette
            itemArray: [{}, { height: 100 }, {}, {}, {}],
          },
        ]);

        myDiagram.mouseDrop = handleDiagramMouseDrop;

        // handles dropping a vending machine into the diagram from the palette
        myDiagram.addDiagramListener("ExternalObjectsDropped", (e) => {
          handleExternalObjectDropped(e.subject.first());
        });

        handleAllDuplicates();
        if (!settings.allowDuplicates) findAndRemovePaletteDuplicates();
        save();
      } // end of init function
      document.addEventListener("DOMContentLoaded", init);

      // save model but without the invisible cells
      function save() {
        const noCellsModel = new go.GraphLinksModel(
          myDiagram.model.nodeDataArray.filter(
            (data) =>
              data.category !== "InvisibleCell" &&
              data.category !== "modal" &&
              data.category !== "modalBg" &&
              data.category !== "highlight"
          )
        );
        noCellsModel.nodeDataArray.forEach((data) => {
          // remove the dragging property from the saved model
          delete data.duplicate;
        });
        document.getElementById("mySavedModel").value = noCellsModel.toJson();
        myDiagram.isModified = false;
      }

      // load the model and add invisible cells back
      function load() {
        myDiagram.model = go.Model.fromJson(
          document.getElementById("mySavedModel").value
        );

        myDiagram.startTransaction("load");

        handleAllDuplicates();
        myDiagram.layoutDiagram(true);
        populateWithInvisibleCells();
        updateHighlights();
        myDiagram.updateAllTargetBindings();
        myDiagram.commitTransaction("load");
      }

      function toggleEditMode() {
        // the transaction doesn't actually save it when you undo cause settings.editMode is a global var
        myDiagram.startTransaction("toggle edit mode");
        settings.editMode = settings.editMode ? false : true;
        myDiagram.updateAllTargetBindings();

        myDiagram.commitTransaction("toggle edit mode");
      }

      function toggleAllowDuplicates() {
        myDiagram.startTransaction("toggle allow duplicates");
        settings.allowDuplicates = settings.allowDuplicates ? false : true;
        handleAllDuplicates();
        if (settings.allowDuplicates) {
          initPalette();
        } else {
          findAndRemovePaletteDuplicates();
        }
        myDiagram.updateAllTargetBindings();
        myDiagram.commitTransaction("toggle allow duplicates");
      }
    </script>
  </head>

<div id="sample">
    <div style="width: 100%; display: flex; justify-content: space-between">
        <div
        style="
            width: 135px;
            margin-right: 2px;
            background-color: whitesmoke;
            border: solid 1px black;
        "
        >
        <div class="tabs" id="tabs">
            <div class="tab">
            <input type="radio" id="rd1" name="rd" checked="true" />
            <label class="tab-label" for="rd1">Sodas</label>
            <div class="tab-content">
                <div
                id="sodaPalette"
                style="
                    width: 140px;
                    height: 300px;
                    background-color: whitesmoke;
                "
                ></div>
            </div>
            </div>
            <div class="tab">
            <input type="radio" id="rd4" name="rd" />
            <label class="tab-label" for="rd4">Vending Machines</label>
            <div class="tab-content">
                <div
                id="vendingMachinePaletteDiv"
                style="
                    width: 140px;
                    height: 200px;
                    background-color: whitesmoke;
                "
                ></div>
            </div>
            </div>
            <!-- all settings -->
            <div
            style="display: flex; align-items: center; flex-direction: column"
            >
            <div class="checkbox-wrapper">
                <input type="checkbox" id="showHideButtonsCheckbox" />
                <label for="showHideButtonsCheckbox" onclick="toggleEditMode();">
                <span class="slider-text">EDIT</span>
                </label>
            </div>
            <div class="checkbox-wrapper">
                <input type="checkbox" id="settings.allowDuplicatesCheckbox" />
                <label
                for="settings.allowDuplicatesCheckbox"
                onclick="toggleAllowDuplicates();"
                >
                <span class="slider-text">DUPLICATES</span>
                </label>
            </div>
            </div>
        </div>
        </div>
        <div
        id="myDiagramDiv"
        style="
            flex-grow: 1;
            height: 700px;
            border: solid 1px black;
            background-color: #f9f5e9;
        "
        ></div>
    </div>
    <!-- description! -->
    <p>
        A <em>planogram</em> is a visual representation of a store's products or
        services, often used as a tool to maximize sales. GoJS can be used to build planogramming software for display (such as inventory monitoring)
        or interactive control and shelf layout.
    </p>
    <p>
        <strong>How it works:</strong>
        Use the <a>Palette</a> on the left to drag and drop sodas (cans and
        bottles) onto vending machines in the main diagram area. Sodas are
        implemented as <a>Node</a>s, while vending machines are <a>Group</a>s.
        When you drag a soda over a vending machine, valid drop zones are
        highlighted using invisible helper nodes (they then become visible to show
        the highlight), making sodas snap into the available slots.
    </p>
    <p>
        <strong>Edit Mode & Duplicates:</strong>
        Use the HTML toggles under the palette to turn on and off
        <b>Edit Mode</b> and <b>Allow Duplicate Sodas</b>.
    </p>
    <ul>
        <li>
        When Edit Mode is enabled: allows the user to click buttons to adjust
        the vending machine size (add and delete columns and rows) as well as
        change the heights of rows.
        </li>
        <li>
        When Duplicates are disabled: Dragging a soda out of the palette will
        delete it from the palette, and the palette only shows the types of
        sodas that don't already exist in the diagram. Sodas that appear more
        than once in the diagram get highlighted in red. This sort of feature
        would be useful for planogramming applications where you want to ensure
        each item is unique.
        </li>
    </ul>
    <p>
        <strong>HTML Interaction:</strong>
        The controls and popups in this sample use custom HTML editors. Clicking
        shelf height buttons or add shelf buttons opens HTML input dialogs for
        editing. Right clicking on a soda shows an HTML button allowing you to
        fill the entire row with that particular type of soda.
    </p>
    <p>
        This sample uses
        <a href="../intro/geometry.html">Geometry Path Strings</a>
        to create the shapes of the highlights on the corners of parts of the
        vending machine. It also uses
        <a href="../intro/itemArrays.html">itemArray Panels</a> to display a
        variable number of rows (shelves) in each vending machine, and another
        itemArray Panel in each shelf to display a variable number of columns.
    </p>
    <p>
        This sample uses <a>Animation</a> to make
        the vending machines fall back down to the ground when you move them.
    </p>
    <p>
        See also Northwoods Software's planogramming services:
        <a href="https://goplanogram.com" target="_blank">GoPlanogram</a>.
    </p>
    <button id="SaveButton" onclick="save()">Save</button>
    <button onclick="load()">Load</button>
    Diagram Model saved in JSON format:
    <textarea id="mySavedModel" style="width: 100%; height: 300px"></textarea>
    <!-- everything down here is for the HTMLInfo class. these aren't visible at first but will be visible in the diagram when you click a button that needs a pop up. -->
    <!-- they can't start out in the diagram cause then they get auto deleted -->
    <!-- height custom input -->
    <div class="html-info" id="height-input-div">
        <input />
        <div class="arrows">
            <div class="arrow up" onclick="changeHeightInput(10)">▲</div>
            <div class="arrow down" onclick="changeHeightInput(-10)">▼</div>
        </div>
    </div>
    <!-- add row custom input -->
    <div class="html-info" id="add-row-input-div">
        <div class="arrow up">▲</div>
        <div>
        <input id="row-input" />
        <p>row(s)</p>
        <input id="height-input" />
        <p>px tall</p>
        </div>
        <div class="arrow down">▼</div>
    </div>
    <button class="html-info" id="fill-shelf-button" onclick="fillShelf()">
        Fill Shelf
    </button>
</div>

          
        </div>
        <!-- * * * * * * * * * * * * * -->
        <!--  End of GoJS sample code  -->
      </div>

      <div id="allTagDescriptions" class="p-4 w-full max-w-screen-xl mx-auto">
        <hr/>
        <h3 class="text-xl">GoJS Features in this sample</h3>
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Groups</h4>
<p>
The <a href="../api/symbols/Group.html" target="api">Group</a> class is used to treat a collection of <a href="../api/symbols/Node.html" target="api">Node</a>s and <a href="../api/symbols/Link.html" target="api">Link</a>s as if they were a single <a href="../api/symbols/Node.html" target="api">Node</a>.
Those nodes and links are members of the group; together they constitute a subgraph.
</p>
<p>
A subgraph is <em>not</em> another <a href="../api/symbols/Diagram.html" target="api">Diagram</a>, so there is no separate HTML Div element for the subgraph of a group.
All of the <a href="../api/symbols/Part.html" target="api">Part</a>s that are members of a <a href="../api/symbols/Group.html" target="api">Group</a> belong to the same Diagram as the Group.
There can be links between member nodes and nodes outside of the group as well as links between the group itself and other nodes.
There can even be links between member nodes and the containing group itself.
</p>
<p>
More information can be found in the <a href="../intro/groups.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#groups">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Palette</h4>
<p>
A <a href="../api/symbols/Palette.html" target="api">Palette</a> is a subclass of <a href="../api/symbols/Diagram.html" target="api">Diagram</a> that is used to display a number of <a href="../api/symbols/Part.html" target="api">Part</a>s that
can be dragged into the diagram that is being modified by the user.
The initialization of a <a href="../api/symbols/Palette.html" target="api">Palette</a> is just like the initialization of any <a href="../api/symbols/Diagram.html" target="api">Diagram</a>.
Like Diagrams, you can have more than one Palette on the page at the same time.
</p>
<p>
More information can be found in the <a href="../intro/palette.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#palette">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>HTML Interaction</h4>
<p>
GoJS Diagrams can be used alongside other HTML elements in a webapp. 
For custom Text Editors, Context Menus, and ToolTips, which are invoked and hidden via GoJS tool operations, it is best to use the <a href="../api/symbols/HTMLInfo.html" target="api">HTMLInfo</a> class.
</p>
<p>
More information can be found in the <a href="../intro/HTMLinteraction.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#html">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Commands</h4>
<p>
A <a href="../api/symbols/CommandHandler.html" target="api">CommandHandler</a> handles all default keyboard input events in a Diagram.
There are many predefined methods on <a>CommandHandler</a> that implement common commands to operate on the Diagram or the current <a>Diagram.selection></a>.
</p>
<p>
You can override <a>CommandHandler.doKeyDown</a> to handle additional keyboard shortcuts or to change which commands are invoked via the keyboard.
<p>
Your code can invoke a command by calling the appropriate method on the <a>Diagram.commandHandler</a>.
Each command method has a corresponding <b>can...</b> predicate that your code can use to enable or disable any buttons that invoke the command.
Your code can customize the behavior of a command by overriding the method on <a>CommandHandler</a>,
or by setting properties on the <a>CommandHandler</a> or <a>Diagram</a> or <a>Part</a>s --
see <a href="../intro/permissions.html">GoJS Permissions</a>.
</p>
<p>
There are several CommandHandler extensions that provide additional functionality.
<p>
More information can be found in the <a href="../intro/commands.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#commands">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Animation</h4>
<p>
  <b>GoJS</b> offers several built-in animations, enabled by default, as well as the ability to create arbitrary animations.
</p>
<p>
  The <a href="../api/symbols/Diagram.html#animationManager" target="api">Diagram.animationManager</a> handles animations within a <a href="../api/symbols/Diagram.html" target="api">Diagram</a>.
  The <a href="../api/symbols/AnimationManager.html" target="api">AnimationManager</a> automatically sets up and dispatches default animations, and has properties to customize and disable them.
  Custom animations are possible by creating instances of <a href="../api/symbols/Animation.html" target="api">Animation</a> or <a href="../api/symbols/AnimationTrigger.html" target="api">AnimationTrigger</a>.
  More information can be found in the <a href="../intro/animation.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#animation">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Buttons</h4>
<p>
GoJS defines several <a href="../api/symbols/Panel.html" target="api">Panel</a>s for common uses.
These include "Button", "TreeExpanderButton", "SubGraphExpanderButton", "PanelExpanderButton", "ContextMenuButton", and "CheckBoxButton".
"ContextMenuButton"s are typically used inside of "ContextMenu" Panels;
"CheckBoxButton"s are used in the implementation of "CheckBox" Panels.
</p>
<p>
These predefined panels can be used as if they were <a href="../api/symbols/Panel.html" target="api">Panel</a>-derived classes in calls to <a href="../api/symbols/GraphObject.html#build" target="api">GraphObject.build</a>.
They are implemented as simple visual trees of <a href="../api/symbols/GraphObject.html" target="api">GraphObject</a>s in <a href="../api/symbols/Panel.html" target="api">Panel</a>s,
with pre-set properties and event handlers.
</p>
<p>
More information can be found in the <a href="../intro/buttons.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#buttons">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Geometry Path Strings</h4>
<p>
The <b>GoJS</b> <a href="../api/symbols/Geometry.html" target="api">Geometry</a> class controls the "shape" of a <a href="../api/symbols/Shape.html" target="api">Shape</a>,
whereas the <a href="../api/symbols/Shape.html#fill" target="api">Shape.fill</a> and <a href="../api/symbols/Shape.html#stroke" target="api">Shape.stroke</a> and other shape properties control the colors and appearance of the shape.
For common shape figures, there are predefined geometries that can be used by setting <a href="../api/symbols/Shape.html#figure" target="api">Shape.figure</a>.
However one can also define custom geometries.
</p>
<p>
One can construct any Geometry by allocating and initializing a <a href="../api/symbols/Geometry.html" target="api">Geometry</a> of at least one <a href="../api/symbols/PathFigure.html" target="api">PathFigure</a> holding some <a href="../api/symbols/PathSegment.html" target="api">PathSegment</a>s.
But you may find that using the string representation of a Geometry is easier to write and save in a database.
Use the static method <a href="../api/symbols/Geometry.html#parse" target="api">Geometry.parse</a> or the <a href="../api/symbols/Shape.html#geometryString" target="api">Shape.geometryString</a> property to transform a geometry path string into a <a href="../api/symbols/Geometry.html" target="api">Geometry</a> object.
</p>
<p>
More information can be found in the <a href="../intro/geometry.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#geometries">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Item Arrays</h4>
<p>
It is sometimes useful to display a variable number of elements in a node by data binding to a JavaScript Array.
In GoJS, this is simply achieved by binding (or setting) <a href="../api/symbols/Panel.html#itemArray" target="api">Panel.itemArray</a>.
The <a href="../api/symbols/Panel.html" target="api">Panel</a> will create an element in the panel for each value in the Array.
More information can be found in the <a href="../intro/itemArrays.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#itemarrays">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Table Panels</h4>
<p>
The "Table" Panel, <a href="../api/symbols/Panel.html#static-Table" target="api">Panel.Table</a>, arranges objects in rows and columns.
Each object in a Table Panel is put into the cell indexed by the value of <a href="../api/symbols/GraphObject.html#row" target="api">GraphObject.row</a> and <a href="../api/symbols/GraphObject.html#column" target="api">GraphObject.column</a>.
The panel will look at the rows and columns for all of the objects in the panel to determine how many rows and columns the table should have.
More information can be found in the <a href="../intro/tablePanels.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#tables">Related samples</a>
            </p>
            <hr>
          
        <!-- blacklist tags that do not correspond to a specific GoJS feature -->
          
            <h4>Tools</h4>
<p>
<a href="../api/symbols/Tool.html" target="api">Tool</a>s handle all input events, such as mouse and keyboard interactions, in a Diagram.
There are many kinds of predefined Tool classes that implement all of the common operations that users do.
</p>
<p>
For flexibility and simplicity, all input events are canonicalized as <a href="../api/symbols/InputEvent.html" target="api">InputEvent</a>s and
redirected by the diagram to go to the <a href="../api/symbols/Diagram.html#currentTool" target="api">Diagram.currentTool</a>.
By default the Diagram.currentTool is an instance of <a href="../api/symbols/ToolManager.html" target="api">ToolManager</a> held as the <a href="../api/symbols/Diagram.html#toolManager" target="api">Diagram.toolManager</a>.
The ToolManager implements support for all mode-less tools.
The ToolManager is responsible for finding another tool that is ready to run and then making it the new current tool.
This causes the new tool to process all of the input events (mouse, keyboard, and touch) until the tool decides that it is finished,
at which time the diagram's current tool reverts back to the <a href="../api/symbols/Diagram.html#defaultTool" target="api">Diagram.defaultTool</a>, which is normally the ToolManager, again.
</p>
<p>
More information can be found in the <a href="../intro/tools.html">GoJS Intro</a>.
</p>
            <p>
              <a href="../samples/index.html#tools">Related samples</a>
            </p>
            <hr>
          
        </div>
    </div>
  </body>
  <!--  This script is part of the gojs.net website, and is not needed to run the sample -->
  <script src="../assets/js/goSamples.js"></script>
</html>