Dynamic Grid (with subgrid)

Hi,

we want to change the grid of a diagram dynamically baed on a gridSize.
The idea is to have the default grid, which can be changed in size via a slider option.
Additionally, we have a pagination grid that is a sub grid that indicates page sizes like A4, A3 etc.
The page size and page orientation are dynamically changeable.

I tried a lot of stuff but couldnt get a nice and clean implementation working.
Before we had a solution in which we set grid properties (like gridCellSize) directly.
Ideally, we want a data driven approach, like with all other templates we create.

I now found out that the grid does not support bindings in this topic: How to dynamically change colour of grid
This is really unfortunate. Is there any reason as to why it doesnt support bindings?
How would one ideally implement what we want?
I attach the grid template I have so far.

export function createGridTemplate(): go.Panel {
const $ = go.GraphObject.make;

return $(
    go.Panel,
    go.Panel.Grid,
    {
        // Use 1x1 base cell so that intervals represent exact pixel distances for the default and pagination grid respectively.
        gridCellSize: new go.Size(1, 1),
        data: { gridSize: 25, paginationV: 1, paginationH: 1 },
    },
    // Default grid lines
    $(
        go.Shape,
        'LineH',
        {
            stroke: 'lightgrey',
            strokeWidth: 0.5,
            interval: 25,
            name: 'DefaultGridH',
        },
        new go.Binding('interval', 'gridSize'),
    ),
    $(
        go.Shape,
        'LineV',
        {
            stroke: 'lightgrey',
            strokeWidth: 0.5,
            interval: 25,
            name: 'DefaultGridV',
        },
        new go.Binding('interval', 'gridSize', (v: number) => {
            console.log(v);
            return v;
        }),
    ),
    // Pagination grid lines
    $(
        go.Shape,
        'LineH',
        {
            stroke: '#2d3842',
            strokeDashArray: [2, 2],
            strokeWidth: 0.5,
            interval: 1,
            name: 'PaginationGridH',
        },
        new go.Binding('interval', 'paginationH', (v: number) => {
            console.log(v);
            return v;
        }),
    ),
    $(
        go.Shape,
        'LineV',
        {
            stroke: '#2d3842',
            strokeDashArray: [2, 2],
            strokeWidth: 0.5,
            name: 'PaginationGridV',
        },
        new go.Binding('interval', 'paginationV'),
    ),
);

}

Example screenshot.

Cheers.

To be clear, there should be no problems with data binding of a “Grid” Panel in a regular Part (Node or Link) representing data in a model. The problem is that the Diagram.grid is special because it is automatically modified by the diagram as the user scrolls or zooms. Furthermore it is special because it should exist and work even when there is no node data (or link data) in the model. So the Part that holds the Diagram.grid has to be an unmodeled Part, and all changes to it must not be recorded in the UndoManager.

But with a bit of hacking it is possible to implement data binding in the special Diagram.grid, but it isn’t designed functionality of the GoJS library.

<!DOCTYPE html>
<html>
<head>
  <title>Hack for data binding in Diagram.grid</title>
  <!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Test</button>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      grid: new go.Panel("Grid")
        .add(
          new go.Shape("BarH", { fill: "#FF000010", strokeWidth: 0, interval: 2 })
            .bind("fill", "color")
            .bind("interval"),
          new go.Shape("BarV", { fill: "#FF000010", strokeWidth: 0, interval: 2 })
            .bind("fill", "color")
            .bind("interval")
        ),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.model = new go.GraphLinksModel(
  {
    modelData: { color: "#00FF0010" },
    nodeDataArray:
      [
        { key: 1, text: "Alpha", color: "lightblue" },
        { key: 2, text: "Beta", color: "orange" },
        { key: 3, text: "Gamma", color: "lightgreen" },
        { key: 4, text: "Delta", color: "pink" }
      ],
    linkDataArray:
      [
        { from: 1, to: 2 },
        { from: 1, to: 3 },
        { from: 2, to: 2 },
        { from: 3, to: 4 },
        { from: 4, to: 1 }
      ]
  });

// Change the Diagram's special grid Part to be data bound.
// Can't use a node data object, because it needs to work even if there are no Nodes or Links
// (i.e. Model.nodeDataArray could be empty).
// Try using the shared Model.modelData. However this could be inefficient
// Furthermore because the grid Part is in a Layer that is Layer.isTemporary,
// no changes in the grid Part will be recorded in the UndoManager.
myDiagram.grid.part.data = myDiagram.model.modelData;

document.getElementById("myTestButton").addEventListener("click", e => {
  myDiagram.model.commit(m => {
    m.set(m.modelData, "color", m.modelData.color === "#00FF0010" ? "#FF000010" : "#00FF0010");
    m.set(m.modelData, "interval", m.modelData.interval > 2 ? 2 : 3)
  }, null);  // don't want to record these Model.modelData changes
});
  </script>
</body>
</html>

I tried this now but the binding in the grid template doesn’t do anything, which is sad because this is the exact way we would want to implement it. Have bindings to properties of the diagram modelData and the grid changes not being in the undo manager.

Did the example app work for you? How is your code different?

I modified your example and added an input field and another button to change more stuff. The pagination button changes a modelData property which should affect the additional pagination grid lines.
Both button and input have no effect.

<!DOCTYPE html>
<html>
<head>
  <title>Hack for data binding in Diagram.grid</title>
  <!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
  <button id="myTestButton">Test</button>
  <button id="pageIntervalTestButton">Pagination Grid Change</button>
  <input id="intervalInput"></input>
  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      grid: new go.Panel("Grid")
        .add(
          new go.Shape("LineH", { stroke: "lightgrey", strokeWidth: 1, interval: 5 })
            .bind("stroke", "color")
            .bind("interval"),
          new go.Shape("LineV", { stroke: "lightgrey", strokeWidth: 1, interval: 5 })
            .bind("stroke", "color")
            .bind("interval"),
		  new go.Shape("LineH", { stroke: "#AA4A44", strokeWidth: 1, strokeDashArray: [2, 2], interval: 30 })
            .bind("interval", "pageInterval"),
          new go.Shape("LineV", { stroke: "#AA4A44", strokeWidth: 1, strokeDashArray: [2, 2], interval: 30 })
            .bind("interval", "pageInterval")
        ),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.model = new go.GraphLinksModel(
  {
    modelData: { color: "lightgrey" },
    nodeDataArray:
      [
        { key: 1, text: "Alpha", color: "lightblue" },
        { key: 2, text: "Beta", color: "orange" },
        { key: 3, text: "Gamma", color: "lightgreen" },
        { key: 4, text: "Delta", color: "pink" }
      ],
    linkDataArray:
      [
        { from: 1, to: 2 },
        { from: 1, to: 3 },
        { from: 2, to: 2 },
        { from: 3, to: 4 },
        { from: 4, to: 1 }
      ]
  });

// Change the Diagram's special grid Part to be data bound.
// Can't use a node data object, because it needs to work even if there are no Nodes or Links
// (i.e. Model.nodeDataArray could be empty).
// Try using the shared Model.modelData. However this could be inefficient
// Furthermore because the grid Part is in a Layer that is Layer.isTemporary,
// no changes in the grid Part will be recorded in the UndoManager.
myDiagram.grid.part.data = myDiagram.model.modelData;

document.getElementById("myTestButton").addEventListener("click", e => {
  myDiagram.model.commit(m => {
    m.set(m.modelData, "interval", m.modelData.interval === 5 ? 3 : 5)
  }, null);  // don't want to record these Model.modelData changes
});
document.getElementById("pageIntervalTestButton").addEventListener("click", e => {
  myDiagram.model.commit(m => {
    m.set(m.modelData, "pageInterval", m.modelData.pageInterval === 50 ? 25 : 50)
  }, null);  // don't want to record these Model.modelData changes
});
document.getElementById("intervalInput").addEventListener("input", e => {
  myDiagram.model.commit(m => {
    const value = Number(e.target.value);
    m.set(m.modelData, "interval", value);
  }, null);  // don't want to record these Model.modelData changes
});
  </script>
</body>
</html>

Actually, they do have effect, but the user doesn’t see it right away. Try scrolling/panning/zooming, or change selection, and you’ll see the correct background grid.

So the problem is that there is a bug – just modifying Shape.interval doesn’t cause the background grid to be redrawn. Whereas in my example code above I was also modifying the stroke or fill. You can temporarily implement a work-around by calling Diagram.redraw after the call to commit. Thanks for reporting this bug.

I am glad to hear that this is an actual bug and I will wait until you release a new hotfix version.
Will try it again with the new version once it is released.
Thanks Walter.

For the record, the fix will be in 3.1.6.

1 Like

Thanks Walter!
The example above now works properly. I will change our code soon and use the approach from the example above.

I finally got to test the fix in our code but it still does not work as expected.

The pagination lines are directly tied to the interval of the standard gridCellSize, so I assume the gridCellSize influences the entire “node”, so all shapes within the grid template. Is that correct?

I revised my example to visualize what we want to achieve.
The grid size (standard grid lines) should be tied to one value e.g. 25 so the standard grid lines have a 25px interval.
The pagination lines should be completely independent from that and only change when we change another value e.g. pageInterval.
In our code, in practice, we have 2 values that define the pagination size, so we would need 2 bindings for the pagination lines.

In the revised example, you can also see that using the “pagination grid change” button changes the standard grid lines. There are gaps, where the pagination lines were previously.

<!DOCTYPE html>
<html>
<head>
  <title>Hack for data binding in Diagram.grid</title>
  <!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>

  <button id="myTestButton">Test</button>
  <button id="pageIntervalTestButton">Pagination Grid Change</button>

  <input id="intervalInput" />
  
  <div style="margin: 10px 0;">
    <label for="gridCellSizeSlider">
      Grid cell size:
      <span id="gridCellSizeValue">10</span> px
    </label>
    <input
      id="gridCellSizeSlider"
      type="range"
      min="2"
      max="80"
      step="1"
      value="10"
      style="width: 320px; vertical-align: middle;"
    />
  </div>

  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      grid: new go.Panel("Grid")
        .add(
          new go.Shape("LineH", { stroke: "lightgrey", strokeWidth: 1, interval: 5 })
            .bind("stroke", "color"),
          new go.Shape("LineV", { stroke: "lightgrey", strokeWidth: 1, interval: 5 })
            .bind("stroke", "color"),
          new go.Shape("LineH", { stroke: "green", strokeWidth: 2, strokeDashArray: [2, 2], interval: 15 })
            .bind("interval", "pageInterval", (v)=>{
			  console.log(v);
			  return v;
			}),
          new go.Shape("LineV", { stroke: "green", strokeWidth: 2, strokeDashArray: [2, 2], interval: 15 })
            .bind("interval", "pageInterval", (v)=>{
			  console.log(v);
			  return v;
			})
        ),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.model = new go.GraphLinksModel(
  {
    modelData: { color: "lightgrey" },
    nodeDataArray:
      [
        { key: 1, text: "Alpha", color: "lightblue" },
        { key: 2, text: "Beta", color: "orange" },
        { key: 3, text: "Gamma", color: "lightgreen" },
        { key: 4, text: "Delta", color: "pink" }
      ],
    linkDataArray:
      [
        { from: 1, to: 2 },
        { from: 1, to: 3 },
        { from: 2, to: 2 },
        { from: 3, to: 4 },
        { from: 4, to: 1 }
      ]
  });

// Change the Diagram's special grid Part to be data bound.
myDiagram.grid.part.data = myDiagram.model.modelData;

document.getElementById("myTestButton").addEventListener("click", e => {
  const size = myDiagram.grid.gridCellSize.width
  setGridCellSize(size === 5 ? 3 : 5);
});

document.getElementById("pageIntervalTestButton").addEventListener("click", e => {
  myDiagram.model.commit(m => {
    m.set(m.modelData, "pageInterval", m.modelData.pageInterval === 15 ? 25 : 15)
  }, null);  // don't want to record these Model.modelData changes
});

document.getElementById("intervalInput").addEventListener("input", e => {
  myDiagram.model.commit(m => {
    const value = Number(e.target.value);
    m.set(m.modelData, "interval", value);
  }, null);  // don't want to record these Model.modelData changes
});

const gridCellSizeSlider = document.getElementById("gridCellSizeSlider");
const gridCellSizeValue = document.getElementById("gridCellSizeValue");

function setGridCellSize(px) {
  const value = Math.max(1, Number(px) || 1);
  gridCellSizeValue.textContent = String(value);

  // Update the Diagram grid cell size (not the line "interval")
  myDiagram.commit(() => {
    myDiagram.grid.gridCellSize = new go.Size(value, value);
  }, "change gridCellSize");
}

gridCellSizeSlider.addEventListener("input", (e) => {
  setGridCellSize(e.target.value);
});
  </script>
</body>
</html>

The interval property must be an integer that controls the distance between lines as a multiple of the Panel.gridCellSize. So if you want your page grid to be independent of the cell grid (i.e. not integral multiples of the gridCellSize) you will have to do something like set the gridCellSize to 1x1 and then set/bind the cell size to the desired pixel size and set/bind the page size to the desired pixel size. But they must all be integral – no fractions.

Here’s an example. I took out the color bindings, assumed grid cells are square, but assumed the page grid cells are not square. However, since there is only one “Grid” Panel, you cannot control the grid origins for the two grids independently.

<!DOCTYPE html>
<html>
<head>
  <title>Hack for data binding in Diagram.grid</title>
  <!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
</head>
<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>

  <div style="margin: 10px 0;">
    <label>
      Grid size:
      <span id="gridCellSizeValue">10</span> px
      <input
        id="gridCellSizeSlider"
        type="range"
        min="2"
        max="100"
        step="1"
        value="10"
        style="width: 320px; vertical-align: middle;"
      />
    </label>
  </div>

  <div style="margin: 10px 0;">
    <label>
      Page width:
      <span id="pageWidthValue">100</span> px
      <input
        id="pageWidthSlider"
        type="range"
        min="100"
        max="1000"
        step="1"
        value="100"
        style="width: 320px; vertical-align: middle;"
      />
    </label>
  </div>

  <div style="margin: 10px 0;">
    <label>
      Page height:
      <span id="pageHeightValue">100</span> px
      <input
        id="pageHeightSlider"
        type="range"
        min="100"
        max="1000"
        step="1"
        value="100"
        style="width: 320px; vertical-align: middle;"
      />
    </label>
  </div>

  <textarea id="mySavedModel" style="width:100%;height:250px"></textarea>

  <script src="https://cdn.jsdelivr.net/npm/gojs"></script>
  <script id="code">
const myDiagram =
  new go.Diagram("myDiagramDiv", {
      grid: new go.Panel("Grid", { gridCellSize: new go.Size(1, 1) })
        .add(
          new go.Shape("LineH", { stroke: "lightgrey", strokeWidth: 1, interval: 10 })
            .bind("interval", "cellSize", s => s.height),
          new go.Shape("LineV", { stroke: "lightgrey", strokeWidth: 1, interval: 10 })
            .bind("interval", "cellSize", s => s.width),
          new go.Shape("LineH", { stroke: "green", strokeWidth: 2, strokeDashArray: [2, 2], interval: 100 })
            .bind("interval", "pageSize", s => s.height),
          new go.Shape("LineV", { stroke: "green", strokeWidth: 2, strokeDashArray: [2, 2], interval: 100 })
            .bind("interval", "pageSize", s => s.width)
        ),
      "undoManager.isEnabled": true,
      "ModelChanged": e => {     // just for demonstration purposes,
        if (e.isTransactionFinished) {  // show the model data in the page's TextArea
          document.getElementById("mySavedModel").textContent = e.model.toJson();
        }
      }
    });

myDiagram.nodeTemplate =
  new go.Node("Auto")
    .add(
      new go.Shape({ fill: "white" })
        .bind("fill", "color"),
      new go.TextBlock({ margin: 8 })
        .bind("text")
    );

myDiagram.model = new go.GraphLinksModel(
  {
    modelData: { cellSize: new go.Size(10, 10), pageSize: new go.Size(100, 100) },
    nodeDataArray:
      [
        { key: 1, text: "Alpha", color: "lightblue" },
        { key: 2, text: "Beta", color: "orange" },
        { key: 3, text: "Gamma", color: "lightgreen" },
        { key: 4, text: "Delta", color: "pink" }
      ],
    linkDataArray:
      [
        { from: 1, to: 2 },
        { from: 1, to: 3 },
        { from: 2, to: 2 },
        { from: 3, to: 4 },
        { from: 4, to: 1 }
      ]
  });

// Change the Diagram's special grid Part to be data bound.
myDiagram.grid.part.data = myDiagram.model.modelData;

const gridCellSizeValue = document.getElementById("gridCellSizeValue");
const gridCellSizeSlider = document.getElementById("gridCellSizeSlider");

gridCellSizeSlider.addEventListener("input", e => {
  setGridCellSize(e.target.value);
});

function setGridCellSize(px) {
  const value = Math.round(Math.max(2, Number(px)));
  gridCellSizeValue.textContent = String(value);
  myDiagram.model.commit(m => {
    m.set(m.modelData, "cellSize", new go.Size(value, value));
  }, null);
}

const pageWidthValue = document.getElementById("pageWidthValue");
const pageWidthSlider = document.getElementById("pageWidthSlider");

pageWidthSlider.addEventListener("input", e => {
  setPageWidth(e.target.value);
});

function setPageWidth(w) {
  const value = Math.round(Math.max(100, Number(w)));
  pageWidthValue.textContent = String(value);
  myDiagram.model.commit(m => {
    m.set(m.modelData, "pageSize", new go.Size(value, m.modelData.pageSize.height));
  }, null);
}

const pageHeightValue = document.getElementById("pageHeightValue");
const pageHeightSlider = document.getElementById("pageHeightSlider");

pageHeightSlider.addEventListener("input", e => {
  setPageHeight(e.target.value);
});

function setPageHeight(h) {
  const value = Math.round(Math.max(100, Number(h)));
  pageHeightValue.textContent = String(value);
  myDiagram.model.commit(m => {
    m.set(m.modelData, "pageSize", new go.Size(m.modelData.pageSize.width, value));
  }, null);
}

  </script>
</body>
</html>

Hmmm, there seems to be another bug, this time with drawing computation, with the Diagram.grid. We’ll look into it.

This makes a lot of sense. Thanks for the modified example.

So my feeling about something still being wrong seems to be correct. Not going insane over this just yet 🙂
Looking forward to your finding(s).

But you’ll note that the grid should draw correctly if you zoom in or out and pan or scroll.

Because the grid gets re-rendered when the viewBounds change, I assume.
You can reproduce it consistently, even after zooming, scrolling or panning.

This will be fixed in v3.1.7, which should come out next week. Thanks for reporting the bugs and for your patience.

Thanks for looking into it and fixing it so fast.
I will check it out again once you release it and report back in here.

It works! Thats a good thing.

But I noticed that the performance tanks when setting the grid size, “cellSize” in our example, above 30px. The diagram then eats your whole cpu. You can observe this in the example too.

Interestingly enough, setting the pagination size does not seem to have a performance impact.

The bigger the grid size, the bigger the area occupied by the computed bitmap that tiles the whole viewport.

I find that panning/scrolling/zooming or moving a node around works smoothly regardless of the cell size. Also, when the user isn’t actually interacting with the diagram, it does not use any CPU.