<label for="count">count</label>
<input id="count" type="range" min="8" max="30" value="20"></input>
<label for="gap">gap</label>
<input id="gap" type="range" min="0" max="30" value="10"></input>
<div id="paper"></div>
<a target="_blank" href="https://www.jointjs.com">
  <img id="logo" src="https://assets.codepen.io/7589991/jointjs-logo.svg" width="200" height="50"></img>
</a>
body {
  font-family: sans-serif;
  padding: 10px;
}

#paper {
  border: 1px solid lightgray;
  margin: 10px 0;
}

#logo {
  position: absolute;
  bottom: 20px;
  right: 20px;
  background-color: #ffffff;
  border: 1px solid #d3d3d3;
  padding: 5px;
  box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.3);
}
const { dia, shapes, util } = joint;

const width = 100;
const height = 80;

const graph = new dia.Graph(
  {},
  {
    cellNamespace: shapes
  }
);

const paper = new dia.Paper({
  el: document.getElementById("paper"),
  model: graph,
  cellViewNamespace: shapes,
  async: true,
  sorting: dia.Paper.sorting.APPROX,
  interactive: false,
  defaultConnectionPoint: {
    name: "rectangle"
  },
  background: { color: "#F3F7F6" }
});

function squareLayout(elements, options = {}) {
  const count = elements.length;
  if (count < 2) return null;

  const { x = 0, y = 0, gap = 10 } = options;
  const { width, height } = elements[0].size();

  let firstRowElementCount = Math.ceil(count / 4) + 1;
  let middleRowsCount = Math.ceil(count / 4) - 1;
  let lastRowElementCount = count - firstRowElementCount - 2 * middleRowsCount;
  let lastRowElementGap = gap;

  switch (firstRowElementCount - lastRowElementCount) {
    case 0:
      // nothing to do
      break;
    case 1: {
      lastRowElementGap += (width + gap) / (lastRowElementCount - 1);
      break;
    }
    case 2: {
      middleRowsCount -= 1;
      lastRowElementCount += 2;
      break;
    }
    case 3: {
      middleRowsCount -= 1;
      lastRowElementCount += 2;
      lastRowElementGap += (width + gap) / (lastRowElementCount - 1);
      break;
    }
  }

  const totalWidth =
    firstRowElementCount * width + (firstRowElementCount - 1) * gap;
  const totalHeight = (middleRowsCount + 2) * height + middleRowsCount * gap;

  for (let i = 0; i < firstRowElementCount; i++) {
    elements[i].position(x + i * (width + gap), y);
  }
  for (let i = 0; i < middleRowsCount; i++) {
    elements[firstRowElementCount + 2 * i].position(
      x,
      y + (1 + i) * (height + gap)
    );
    elements[firstRowElementCount + 2 * i + 1].position(
      x + (firstRowElementCount - 1) * (width + gap),
      y + (1 + i) * (height + gap)
    );
  }
  for (let i = 0; i < lastRowElementCount; i++) {
    elements[firstRowElementCount + 2 * middleRowsCount + i].position(
      x + i * (width + lastRowElementGap),
      y + (middleRowsCount + 1) * (height + gap)
    );
  }

  return new g.Rect(x, y, totalWidth, totalHeight);
}

const templateElement = new shapes.standard.Rectangle({
  size: {
    width,
    height
  },
  attrs: {
    body: {
      strokeWidth: 2
    },
    label: {
      fontFamily: "sans-serif",
      fontSize: 17
    }
  }
});

function generate(count, options) {
  const root = templateElement.clone().prop({
    attrs: {
      body: {
        fill: "#ff9580"
      },
      label: {
        text: "Rectangular\nLayout"
      }
    }
  });

  const colorFn = util.interpolate.hexColor("#00879b", "#80eaff");
  const els = Array.from({ length: count }).map((_, index) => {
    return templateElement.clone().prop({
      attrs: {
        body: {
          fill: colorFn(index / count)
        },
        label: {
          text: `${index + 1}`
        }
      }
    });
  });

  const links = els.map((el) => {
    return new shapes.standard.Link({
      source: {
        id: root.id
      },
      target: {
        id: el.id
      }
    });
  });

  graph.resetCells([root, ...els, ...links]);
  const bbox = squareLayout(els, options);

  if (bbox) {
    const center = bbox.center();
    root.position(center.x - width / 2, center.y - height / 2);
  }

  paper.fitToContent({
    useModelGeometry: true,
    padding: 20,
    allowNewOrigin: "any"
  });
}

function readInputs() {
  const count = Number(document.getElementById("count").value);
  const gap = Number(document.getElementById("gap").value);
  generate(count, { gap });
}

const debouncedReadInputs = util.debounce(readInputs, 10);

document.getElementById("count").addEventListener("input", debouncedReadInputs);
document.getElementById("gap").addEventListener("input", debouncedReadInputs);

generate(20);

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/jointjs/3.6.0/joint.min.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.4.1/backbone-min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/jointjs/3.6.0/joint.min.js