Dynamic Grid (with subgrid)

I tried to do some performance debugging.It is actually the gpu that isnt fast enough in that case. The debugger tools show that there are a lot of dropped frames.

Before we used a paginationGrid Node in a new temporary layer. This approach does not have that performance degradation.

I wonder why you dont see that the performance is much worse (barely usable) with a bigger gridSize (with our example).

Big grid:

Small grid:

What do you mean by “paginationGrid”?

You don’t have any performance problems scrolling/panning/zooming, do you?

In our application, the “paginationGrid” are the extra lines in the example above (green dotfed lines). The performance in general but especially when moving a node is much worse. I didnt test with only panning/zooming/scrolling

How is that paginationGrid implemented?

I will create an example tomorrow

I created an example that shows how our “paginationGrid” is implemented at the moment.

The standard grid template is overriden for styling but does not use any bindings. We are just setting the diagram.grid.gridCellSize to modify the size.

The pagination grid is a node on a temporary layer. The data of that node is set to the diagram modelData and binds an empty ‘’ binding (if anything of the model changes) in order to notice rows/columns being changed to reflect that via interval on the shape. The node is also only as big as the dimensions require it. We want to (at least partially) circumvent having scroll bars, even if you dont need them, but as you can see it is not an ideal solution. You can test that by dragging a node to the right or bottom.

The standard grid and paginationGrid work completely independent in this implementation.

With this implementation we do not have this performance issue but it is complicated, split and cumbersome to maintain. Thats why I prefer the solution of our previous example.

Also another bug, if you set the page size to LARGE and drag a node to the bottom of the canvas, so that the canvas gets scroll bars, and then scroll down, the standard grid is not being rendered and a white blank instead.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>GoJS Pagination Grid Example</title>
  </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 for="pageSizeSelect">
        Page size:
        <select id="pageSizeSelect" style="margin-left: 8px">
          <option value="SMALL">SMALL</option>
          <option value="MEDIUM" selected>MEDIUM</option>
          <option value="LARGE">LARGE</option>
        </select>
      </label>
    </div>

    <div style="margin: 10px 0">
      <span style="margin-right: 12px">Orientation:</span>

      <label style="margin-right: 16px">
        <input
          type="radio"
          name="orientation"
          value="portrait"
          id="orientationPortrait"
          checked
        />
        Portrait
      </label>

      <label>
        <input
          type="radio"
          name="orientation"
          value="landscape"
          id="orientationLandscape"
        />
        Landscape
      </label>
    </div>

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

    <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
    <script>
      class PaginationDiagram extends go.Diagram {
        computeBounds(rect) {
          const bounds = super.computeBounds(rect);
          const modelData = this.model.modelData;
          const pageSize = getCanvasPageSize(modelData);

          const columns = Math.ceil(bounds.right / pageSize.width) || 1;
          const rows = Math.ceil(bounds.bottom / pageSize.height) || 1;

          if (modelData.columns !== columns) {
            this.model.set(modelData, "columns", columns);
          }
          if (modelData.rows !== rows) {
            this.model.set(modelData, "rows", rows);
          }

          bounds.x = 0;
          bounds.y = 0;
          bounds.width = columns * pageSize.width;
          bounds.height = rows * pageSize.height;
          return bounds;
        }
      }

      const myDiagram = new PaginationDiagram("myDiagramDiv", {
        "undoManager.isEnabled": true,
        "grid.visible": true,
		"initialContentAlignment": go.Spot.TopLeft,
        "contentAlignment": go.Spot.TopLeft,
        grid: new go.Panel(go.Panel.Grid, {
          gridCellSize: new go.Size(10, 10),
        }).add(
          new go.Shape("LineH", { stroke: "#e2e8f0", strokeWidth: 1 }),
          new go.Shape("LineV", { stroke: "#e2e8f0", strokeWidth: 1 }),
        ),
        ModelChanged: (e) => {
          if (e.isTransactionFinished) {
            document.getElementById("mySavedModel").textContent =
              e.model.toJson();
          }
        },
      });

      myDiagram.nodeTemplate = new go.Node("Auto", {
        locationSpot: go.Spot.TopLeft,
      })
        .bindTwoWay("location", "location", go.Point.parse, go.Point.stringify)
        .add(
          new go.Shape({ fill: "white" }).bind("fill", "color"),
          new go.TextBlock({ margin: 8 }).bind("text"),
        );

      myDiagram.model = new go.GraphLinksModel({
        modelData: {
          pageSize: { width: 85, height: 60 },
          columns: 1,
          rows: 1,
          orientation: "portrait",
        },
        nodeDataArray: [
          { key: 1, text: "Alpha", color: "lightblue", location: "0 0" },
          { key: 2, text: "Beta", color: "orange", location: "150 0" },
          { key: 3, text: "Gamma", color: "lightgreen", location: "0 120" },
          { key: 4, text: "Delta", color: "pink", location: "400 180" },
        ],
        linkDataArray: [
          { from: 1, to: 2 },
          { from: 1, to: 3 },
          { from: 2, to: 2 },
          { from: 3, to: 4 },
          { from: 4, to: 1 },
        ],
      });

      function getCanvasPageSize(viewOptions) {
        const pageSize = viewOptions.pageSize;
        const orientation = viewOptions.orientation ?? "portrait";

        if (orientation === "landscape") {
          console.log(orientation);
          return {
            width: Math.max(pageSize.width, pageSize.height),
            height: Math.min(pageSize.width, pageSize.height),
          };
        }

        return {
          width: Math.min(pageSize.width, pageSize.height),
          height: Math.max(pageSize.width, pageSize.height),
        };
      }

      function createPaginationGridTemplate() {
        const $ = go.GraphObject.make;

        const computeDimension = (dim) => (_, targetObj) => {
          const data = targetObj.part.data;
          const pageSize = getCanvasPageSize(data);
          return (
            pageSize[dim] * (dim === "width" ? data.columns : data.rows) + 1
          );
        };

        const computeInterval = (dim) => (_, targetObj) => {
          const data = targetObj.part.data;
          return getCanvasPageSize(data)[dim];
        };

        return $(
          go.Node,
          "Auto",
          {
            layerName: "PaginationGrid",
            location: new go.Point(0, 0),
            selectable: false,
            pickable: false,
          },
          new go.Binding("height", "", computeDimension("height")),
          new go.Binding("width", "", computeDimension("width")),
          $(
            go.Panel,
            go.Panel.Grid,
            { gridCellSize: new go.Size(1, 1) },
            $(
              go.Shape,
              "LineH",
              { stroke: "lightgreen", strokeDashArray: [2, 2], strokeWidth: 3 },
              new go.Binding("interval", "", computeInterval("height")),
            ),
            $(
              go.Shape,
              "LineV",
              { stroke: "lightgreen", strokeDashArray: [2, 2], strokeWidth: 3 },
              new go.Binding("interval", "", computeInterval("width")),
            ),
          ),
        );
      }

      const paginationGridLayer = new go.Layer({
        name: "PaginationGrid",
        isTemporary: true,
        isInDocumentBounds: false,
        allowSelect: false,
      });

      const gridLayer = myDiagram.findLayer("Grid");
      myDiagram.addLayerBefore(paginationGridLayer, gridLayer);

      const paginationGrid = createPaginationGridTemplate();
      paginationGrid.data = myDiagram.model.modelData;
      myDiagram.add(paginationGrid);

      function refreshPaginationGrid() {
        paginationGrid.updateTargetBindings();
      }

      function registerViewOptionsChangedHandler() {
        myDiagram.addModelChangedListener((e) => {
          if (!e.isTransactionFinished) {
            return;
          }

          const transaction = e.object;
          if (!(transaction instanceof go.Transaction)) {
            return;
          }

          if (transaction.name === "Initial Layout") {
            return;
          }

          const hasRelevantChange = transaction.changes.any((change) => {
            if (change.change !== go.ChangeType.Property) {
              return false;
            }

            const propertyName = String(change.propertyName).toLowerCase();
            return (
              propertyName === "rows" ||
              propertyName === "columns" ||
              propertyName === "pagesize" ||
              propertyName === "orientation"
            );
          });

          if (hasRelevantChange) {
            myDiagram.invalidateDocumentBounds();
          }
        });
      }

      myDiagram.addDiagramListener("DocumentBoundsChanged", () => {
        refreshPaginationGrid();
      });

      registerViewOptionsChangedHandler();

      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.grid.gridCellSize = new go.Size(value, value);
      }

      const PAGE_SIZES = {
        SMALL: { width: 50, height: 35 },
        MEDIUM: { width: 85, height: 60 },
        LARGE: { width: 150, height: 120 },
      };

      const pageSizeSelect = document.getElementById("pageSizeSelect");

      pageSizeSelect.addEventListener("change", (e) => {
        setPageSizePreset(e.target.value);
      });

      function setPageSizePreset(name) {
        const pageSize = PAGE_SIZES[name];
        if (!pageSize) {
          return;
        }

        myDiagram.model.commit((m) => {
          m.set(m.modelData, "pageSize", {
            width: pageSize.width,
            height: pageSize.height,
          });
        }, "SetPageSize");
      }

      function syncPageSizeControl() {
        const current = myDiagram.model.modelData.pageSize;

        const match = Object.entries(PAGE_SIZES).find(([, size]) => {
          return size.width === current.width && size.height === current.height;
        });

        if (match) {
          pageSizeSelect.value = match[0];
        }
      }

      const orientationPortrait = document.getElementById(
        "orientationPortrait",
      );
      const orientationLandscape = document.getElementById(
        "orientationLandscape",
      );

      orientationPortrait.addEventListener("change", (e) => {
        if (e.target.checked) {
          setOrientation("portrait");
        }
      });

      orientationLandscape.addEventListener("change", (e) => {
        if (e.target.checked) {
          setOrientation("landscape");
        }
      });

      function setOrientation(orientation) {
        myDiagram.model.commit((m) => {
          m.set(m.modelData, "orientation", orientation);
        }, "SetPageOrientation");
      }

      function syncOrientationControls() {
        const orientation = myDiagram.model.modelData.orientation ?? "portrait";
        orientationPortrait.checked = orientation === "portrait";
        orientationLandscape.checked = orientation === "landscape";
      }

      syncOrientationControls();
      syncPageSizeControl();
      setGridCellSize(10);
    </script>
  </body>
</html>

That bug goes away if you use the latest version:

<script src="https://cdn.jsdelivr.net/npm/gojs"></script>

I didn’t know that you only wanted a partial-infinite space (i.e. positive X and Y coordinates).

I’m not sure why you specify the “columns” and “rows” and “orientation” in the model data. Or did you want to be able to have empty page borders beyond the document bounds, although that is not what you have implemented? That is an advantage of the finite grid that you have implemented versus using the infinite Diagram.grid.

3.1.7 is the latest version, and that version is being used. With that version the white blank happens for me. I can reproduce this consistenly by scrolling down and then up again.

We do not “want” this finite space. Ideally we would just have it integrated into the infinite Diagram.grid. We did it this way, because we saw no other option on how to implement it.

pageSize and orientation define the size of the “page”. Rows and columns are inferred from all nodes. Rows and columns are part of the data we have to persist.
Basically, this is a feature to indicate a standardized page size like A3, A4, Letter, etc. for when our users want to print out the diagram on paper. These are just part of our product requirements.

That’s very odd. Earlier I copied your code (thank you!) into an empty HTML file and ran it, and after I changed to “LARGE” page size, dragged down a node to cause autoscrolling, and then scrolled down all the way, I encountered the undrawn area. I looked at the script tag and saw it was using a specific version which I mistakenly thought wasn’t the latest, so I changed it to “[email protected]” and opened the page again, after which I was unable to reproduce the problem.

Now you correctly point out that I was wrong about the version numbers, but I am still unable to reproduce the problem. So I’m not sure if there could have been a wrongly cached version of the GoJS library (seems unlikely), or I’m just unlucky in trying to reproduce the problem. I have been testing it in both Firefox and Chrome.

I have the cache disabled and checked the response in the network tab. The response includes the 3.1.7 version. I also tried it with chrome and firefox. I get the issue in both browsers.

Interestingly enough, i cannot reproduce it with 3.1.6

Can you reliably reproduce the (non-drawing) bug?

First I always switch Page size to “LARGE”.

Second I drag “Delta” node down to the bottom of the viewport overlapping the bottom edge of the view, causing auto-scroll to happen. Then I let go, and the scrollbar usually indicates that there’s more document to show.

Third I click on the scrollbar to scroll down.

Except for that first time when I was confused about the version numbers, I have been unable to reproduce the problem with the undrawn area at the bottom of the viewport. That is the bug that you are seeing, yes?

I can reproduce it 100% of the time. Both browsers. Dev tools open or not.
Also happens without setting initialContentAlignment and/or contentAlignment.
The only inconsistency that I have is that it is randomly happening at the top or bottom. When it happens at the top, it jumps a bit when initially scrolling down.

I recorded this video to show it.
chrome_SJG1kIjryK.mp4 video-to-gif output image

Streamable mirror (for better quality)

Thinking about it, we noticed this issue a few years ago already in our app. We always brushed it off as weird browser behaviour.

Thanks again for the video. As far as I can tell, I’m doing the same actions that you are. I can see it failing in your video, but it never fails for me in any browser on either a Windows machine or a MacBook. Tomorrow I’ll try on different machines.

As a reminder. I cannot reproduce it at all with 3.1.6.
With 3.1.5, 3.1.4 and 3.1.0 the white blank does not happen either, but the jumping part happens.

Edit: I tried really hard to debug this for an hour but not much luck. The only trail I found is that the grid panel size does not seem to be updated at the moment of the first scroll, and gets then rendered with the wrong size. Only on the second scroll the panel has the correct size.

The blank area seems to be as big as the newly created space e.g. by dragging a node to the bottom. The further you drag it out of bounds, the bigger the blank space without grid. Something something viewport, grid panel size measuring going wrong/not happening.
Cant get much more out of it with minified code.

We’ve fixed this (additional) rendering bug, that will be out in next week’s release.

As for the funny scroll behavior (where you scroll and then it snaps back) - that is a side effect of GoJS animation. It’s the isViewportUnconstrained property of animation:

Basically GoJS lets infinite scroll happen during default animations so that there are no scrollbar constraints on objects, but of course this affects user input as well, which makes it annoying if you scroll while an animation is ongoing.

Unfortunately, we make this somewhat annoying to turn off, because the initial animation turns it on. I think that’s a bug that we can fix for 3.1, but we’re thinking of redoing how that property works for 4.0 completely, since affecting user scroll is too unintended. In the meantime you could either turn off animation, or else write:

      myDiagram.addDiagramListener('InitialAnimationStarting', () => {
        myDiagram.animationManager.defaultAnimation.isViewportUnconstrained = false;
      });

to disable it.

Nice, great work guys!

So animationManager.isEnabled does not disable any and all animation features?. I thought this would completely disable any animation features of the diagram, so no initial or any other animation would even trigger or affect anything.

Anyway. Looking forward to another bugfix release.

Yes, setting AnimationManager.isEnabled to false does disable all animations. That’s why Simon was suggesting something a bit more specific, but a bit more complicated to set.