Shape edges interaction

Hello! I’m using GoJS for creating diagrams containing custom shapes.
The shapes are defined based of an Array of coordinates objects with the structure {x: number, y: number} always having the first and the last point identically, in order to define a closed shape.
For displaying these shapes, I’ve created a group template (this shape should support some children nodes grouped within it) containing a go.Shape binding the geometryString to a computed field based on the Array coordinates. All good for now. works fine and the custom shapes are displayed correctly.

new go.Shape({
      name: 'SHAPE',
      stroke: '#ffffff',
      strokeWidth: 1,
      fill: '#ffffff',
    })
      .bindTwoWay('geometryString', 'geometryStr')
      .bindTwoWay('desiredSize', 'size', go.Size.parse, go.Size.stringify)

Next, what I want to achieve is to interact with each edge of shape. Interaction would mean to select it and mark it in a particular way (highlight specific edges with a different color).
I was trying to achieve this by creating a go.Panel (and adding it to the initial go.Shape) with itemArray, and for each edge, to create a separate go.Shape (as seen below) having a custom color. But this seemed not to work properly (it would not resize with the entire shape, and edges were not properly placed within the diagram).

    new go.Panel('Auto').bind(
      'itemArray',
      'edges',
      (points: Coordinates[], panel: go.Panel) => {
        points.forEach((point, index) => {
          if (index > 0) {
            const geometry = new go.Geometry().add(
              new go.PathFigure(points[index - 1].x, points[index - 1].y).add(
                new go.PathSegment(go.SegmentType.Line, point.x, point.y)
              )
            );
            panel.add(
              new go.Shape({
                geometry: geometry,
                stroke: 'red',
                strokeWidth: 1,
              })
            );
          }
        });
      }

What would be the recommended approach to achieve this behaviour? Thanks!

Would you want to have multiple edges selected and differently colored at the same time?

I’d probably use a “Selection” Adornment that had separate Shapes, one for each edge in the selected Shape’s Geometry.

Could you show an example of what you’d want the user to do?

Updating the Adornment would need to be done at least at each transaction, and maybe even more often by overriding methods of GeometryShapingTool if you are using that.

Thanks for the response.
Currently I’m not using the GeometryReshapingTool, but I plan to use it in the future.
My goal is to give the user the possibility to interact with each edge independently. By interacting I mean, handling click, hover, etc events. e.g: When clicking on a specific edge, to mark it with a different color, and update some property of that node (‘highlightedEdge’ for example). I would like also to keep the default selection Adornment as well for showing the custom shape boundaries.

When I get some free time later today or tomorrow I can experiment on this.

1 Like

Here’s what I have so far, which is just identifying the Line PathSegment that is closest to a particular Point in document coordinates.

Sorry, I don’t have more time today.

<!DOCTYPE html>
<html>
<body>
  <div id="sample">
    <div id="myDiagramDiv" style="border: solid 1px black; width: 100%; height: 550px"></div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/GeometryReshapingTool.js"></script>
  <script id="code">
myDiagram = new go.Diagram('myDiagramDiv', {
  'undoManager.isEnabled': true // enable undo & redo
});

myDiagram.toolManager.mouseDownTools.insertAt(3, new GeometryReshapingTool({ isResegmenting: true }));

function findClosestSegment(shape, docpt) {
  const geo = shape.geometry;
  // assume Path Geometry
  if (geo === null || geo.type !== go.GeometryType.Path) throw new Error("findClosestSegment got a Shape whose Geometry was not of type Path.");
  // assume only one PathFigure
  const fig = geo.figures.first();
  if (fig === null) throw new Error("findClosestSegment got a Geometry without a PathFigure.");
  // assume argument point is in document coordinates, transform into local coordinates for the Shape/Geometry
  const pt = shape.getLocalPoint(docpt);
  const px = pt.x;
  const py = pt.y;
  // get the first point of the PathFigure
  let sx = fig.startX;
  let sy = fig.startY;
  // find the segment that is closest to (PX,PY)
  let bestdist = Infinity;
  let bestidx = 0;
  // now iterate over the PathSegments of the PathFigure
  let i = 0;
  const it = fig.segments.iterator;
  while (it.next()) {
    const seg = it.value;
    const ex = seg.endX;
    const ey = seg.endY;
    // save some time by not calling Math.sqrt
    let dist = 0.0;
    if (seg.type === go.SegmentType.Line) {
      dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, ex, ey);
    } else {
      throw new Error("findClosestSegment got a PathSegment that is not of type Line.");
    }
    if (dist < bestdist) {
      bestdist = dist;
      bestidx = i;
    }
    sx = ex;
    sy = ey;
    i++;
    if (seg.isClosed) {
      dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, fig.startX, fig.startY);
      if (dist < bestdist) {
        bestdist = dist;
        bestidx = i;
      }
    }
  }
  if (bestdist < 20 * 20) return bestidx;
  return -1;
}

myDiagram.nodeTemplate = new go.Node({
    resizable: true,
    resizeObjectName: 'SHAPE',
    reshapable: true, // GeometryReshapingTool assumes nonexistent Part.reshapeObjectName would be "SHAPE"
    rotatable: true,
    rotationSpot: go.Spot.Center,
    click: (e, node) => {
      const idx = findClosestSegment(node.findObject("SHAPE"), e.documentPoint);
      console.log(Date.now(), idx)
    }
  })
  .add(
    new go.Shape({
        name: 'SHAPE',
        fill: 'lightgreen',
        strokeWidth: 1.5
      })
      .bindTwoWay('geometryString', 'geo')
  );

myDiagram.model = new go.GraphLinksModel([
  { geo: 'F M20 0 40 20 20 40 0 20z' },
  { geo: 'F M0 145 L75 8 100 20 120 40 131 87 160 70 180 50 195 0 L249 133z' }
]);
  </script>
</body>
</html>

Thanks for your answer walter!

I understand the approach presented. Getting the index of the segment which is closed to the point that was clicked within the shape (with a defined threshold distance: 20*20 in this case).
I’m interested in the next steps, as I was tackling the problem in another way: first, creating an outline shape (each segment being a separate, based on the segments that form the geometry), and afterwards handling the clicks on the outline shape (replicating the edges).

Looking forward to your take on this, and the next steps provided!

<!DOCTYPE html>
<html>
<body>
  <div id="sample">
    <div id="myDiagramDiv" style="border: solid 1px black; width: 100%; height: 550px"></div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/GeometryReshapingTool.js"></script>
  <script id="code">
myDiagram = new go.Diagram('myDiagramDiv', {
  'undoManager.isEnabled': true // enable undo & redo
});

myDiagram.toolManager.mouseDownTools.insertAt(3, new GeometryReshapingTool({ isResegmenting: true }));

// returns { i, ax, ay, bx, by } describing segment closest to docpt, or else null
function findClosestSegment(shape, docpt) {
  const geo = shape.geometry;
  // assume Path Geometry
  if (geo === null || geo.type !== go.GeometryType.Path) throw new Error("findClosestSegment got a Shape whose Geometry was not of type Path.");
  // assume argument point is in document coordinates, transform into local coordinates for the Shape/Geometry
  const pt = shape.getLocalPoint(docpt);
  const px = pt.x;
  const py = pt.y;
  // find the segment that is closest to (PX,PY)
  let bestdist = Infinity;
  let bestidx = 0;
  // now iterate over the PathSegments of the PathFigures
  let i = 0;
  let ax, ay, bx, by;
  geo.figures.each(fig => {
    // get the first point of the PathFigure
    let sx = fig.startX;
    let sy = fig.startY;
    fig.segments.each(seg => {
      let ex = seg.endX;
      let ey = seg.endY;
      // save some time by not calling Math.sqrt
      let dist = 0.0;
      if (seg.type === go.SegmentType.Line) {
        dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, ex, ey);
      } else if (seg.type === go.SegmentType.Move) {
        sx = seg.endX;
        sy = seg.endY;
        return;
      } else {
        throw new Error("findClosestSegment got a PathSegment that is not of type Line.");
      }
      if (dist < bestdist) {
        bestdist = dist;
        bestidx = i;
        ax = sx; ay = sy; bx = ex; by = ey;
      }
      sx = ex;
      sy = ey;
      i++;
      if (seg.isClosed) {
        dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, fig.startX, fig.startY);
        if (dist < bestdist) {
          bestdist = dist;
          bestidx = i;
          ax = sx; ay = sy; bx = ex; by = ey;
        }
      }
    });
  });
  if (bestdist < 20 * 20) return { i: bestidx, ax, ay, bx, by };
  return null;
}

myDiagram.nodeTemplate = new go.Node({
    resizable: true,
    resizeObjectName: 'SHAPE',
    reshapable: true, // GeometryReshapingTool assumes nonexistent Part.reshapeObjectName would be "SHAPE"
    rotatable: true,
    rotationSpot: go.Spot.Center,
    click: (e, node) => {
      const shp = node.findObject("SHAPE");
      if (!shp) return;
      const info = findClosestSegment(shp, e.documentPoint);
      if (!info) return;
      let ad = node.findAdornment("Segments");
      if (!ad) {
        ad = new go.Adornment("Position")
          .add(
            new go.Placeholder(),
            new go.Panel("Position")
          );
        ad.adornedObject = shp;
        node.addAdornment("Segments", ad);
      }
      // create the Shape showing the highlighted segment
      // this could be of any stroke color or thickness
      const geo = new go.Geometry(go.GeometryType.Line);
      geo.startX = info.ax;
      geo.startY = info.ay;
      geo.endX = info.bx;
      geo.endY = info.by;
      ad.elt(1).add(new go.Shape({
        stroke: "red", strokeWidth: 3, fill: null,
        geometry: geo
      }));
    }
  })
  .add(
    new go.Shape({
        name: 'SHAPE',
        fill: 'lightgreen',
        strokeWidth: 1.5
      })
      .bindTwoWay('geometryString', 'geo')
  );

myDiagram.model = new go.GraphLinksModel([
  { geo: 'F M20 0 40 20 20 40 0 20z' },
  { geo: 'F M0 145 L75 8 100 20 120 40 131 87 160 70 180 50 195 0 L249 133z' }
]);
  </script>
</body>
</html>

Thanks for the solution walter. Works as expected!

Is there any way to have this responsive to the parent shape resize ? Or how should I approach this ?

There needs to be an updateAdornment function that updates the Adornment’s Shape(s) to correspond to the actual Adornment.adornedObject’s Shape.geometry. Each Shape in the Adornment needs to remember which segment it’s showing.

Then that updateAdornment function needs to be called as the node is resized or reshaped. You can either do that at the end of the resize or reshape operation, or you can do it continually by customizing the respective tools by overriding a method (or two?) so that the updateAdornment function is called on each move move.

<!DOCTYPE html>
<html>
<body>
  <div id="sample">
    <div id="myDiagramDiv" style="border: solid 1px black; width: 100%; height: 550px"></div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/GeometryReshapingTool.js"></script>
  <script id="code">
myDiagram = new go.Diagram('myDiagramDiv', {
  'undoManager.isEnabled': true // enable undo & redo
});

myDiagram.toolManager.mouseDownTools.insertAt(3, new GeometryReshapingTool({
  isResegmenting: false,
  // override this in order to update the "Segments" Adornment as well as the "GeometryReshaping" Adornment
  updateAdornments: function(part) {
    GeometryReshapingTool.prototype.updateAdornments.call(this, part);
    // this "Segments" Adornment might be present and need to be updated even when the PART is not selected
    updateHighlights(part);
  } 
}));

// returns { i, ax, ay, bx, by } describing segment closest to docpt, or else null
function findClosestSegment(shape, docpt) {
  const geo = shape.geometry;
  // assume Path Geometry
  if (geo === null || geo.type !== go.GeometryType.Path) throw new Error("findClosestSegment got a Shape whose Geometry was not of type Path.");
  // assume argument point is in document coordinates, transform into local coordinates for the Shape/Geometry
  const pt = shape.getLocalPoint(docpt);
  const px = pt.x;
  const py = pt.y;
  // find the segment that is closest to (PX,PY)
  let bestdist = Infinity;
  let bestidx = 0;
  // now iterate over the PathSegments of the PathFigures
  let i = 0;
  let ax, ay, bx, by;
  geo.figures.each(fig => {
    // get the first point of the PathFigure
    let sx = fig.startX;
    let sy = fig.startY;
    fig.segments.each(seg => {
      let ex = seg.endX;
      let ey = seg.endY;
      // save some time by not calling Math.sqrt
      let dist = 0.0;
      if (seg.type === go.SegmentType.Line) {
        dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, ex, ey);
      } else if (seg.type === go.SegmentType.Move) {
        sx = seg.endX;
        sy = seg.endY;
        return;
      } else {
        throw new Error("findClosestSegment got a PathSegment that is not of type Line.");
      }
      if (dist < bestdist) {
        bestdist = dist;
        bestidx = i;
        ax = sx; ay = sy; bx = ex; by = ey;
      }
      sx = ex;
      sy = ey;
      i++;
      if (seg.isClosed) {
        ex = fig.startX;
        ey = fig.startY;
        dist = go.Point.distanceLineSegmentSquared(px, py, sx, sy, ex, ey);
        if (dist < bestdist) {
          bestdist = dist;
          bestidx = i;
          ax = sx; ay = sy; bx = ex; by = ey;
        }
        i++;
      }
    });
  });
  if (bestdist < 20 * 20) return { i: bestidx, ax, ay, bx, by };
  return null;
}

function addHighlight(e, node) {
  const shp = node.findObject("SHAPE");
  if (!shp) return;
  const info = findClosestSegment(shp, e.documentPoint);
  if (!info) return;
  const sw = 3;  // strokeWidth for the highlight Shapes
  let ad = node.findAdornment("Segments");
  if (!ad) {
    ad = new go.Adornment("Position")
      .add(
        new go.Placeholder({ position: new go.Point(sw/4, sw/4) }),
        new go.Panel("Position")
      );
    ad.adornedObject = shp;
    node.addAdornment("Segments", ad);
  }
  const shpPanel = ad.elt(1);
  let highshp = null;
  // find an existing Shape used to highlight this segment
  const it = shpPanel.elements;
  while (it.next()) {
    const shape = it.value;
    if (shape.segmentIndex === info.i) {
      highshp = shape;
      break;
    }
  }
  if (!highshp) {  // if not present, create it
    // create the Shape showing the highlighted segment
    // this could be of any stroke color or thickness
    highshp = new go.Shape({
      stroke: "red", strokeWidth: sw, fill: null,
      isGeometryPositioned: true,
      segmentIndex: info.i
    });
    shpPanel.add(highshp);
  }
  updateHighlights(node);
}

function updateHighlights(node) {
  const shp = node.findObject("SHAPE");
  if (!shp) return;
  const geo = shp.geometry;
  if (geo.type !== go.GeometryType.Path) return;
  const ad = node.findAdornment("Segments");
  if (!ad) return;

  const shpPanel = ad.elt(1);
  const fig = geo.figures.first();  //??? just do one PathFigure
  shpPanel.elements.each(shape => {
    let sx, sy, ex, ey;
    const idx = shape.segmentIndex;
    if (idx === 0) {
      const firstseg = fig.segments.elt(0);
      sx = fig.startX;
      sy = fig.startY;
      ex = firstseg.endX;
      ey = firstseg.endY;
    } else if (idx >= fig.segments.count) {
      const lastseg = fig.segments.elt(fig.segments.count-1);
      sx = lastseg.endX;
      sy = lastseg.endY;
      ex = fig.startX;
      ey = fig.startY;
    } else {
      const prevseg = fig.segments.elt(idx-1);
      const nextseg = fig.segments.elt(idx);
      sx = prevseg.endX;
      sy = prevseg.endY;
      ex = nextseg.endX;
      ey = nextseg.endY;
    }
    // update the geometry for the highlight segment
    const geo = new go.Geometry(go.GeometryType.Line);
    geo.startX = sx;
    geo.startY = sy;
    geo.endX = ex;
    geo.endY = ey;
    shape.geometry = geo;
  });
}

myDiagram.nodeTemplate = new go.Node({
    resizable: true,
    resizeObjectName: 'SHAPE',
    reshapable: true, // GeometryReshapingTool assumes nonexistent Part.reshapeObjectName would be "SHAPE"
    rotatable: true,
    rotationSpot: go.Spot.Center,
    click: addHighlight,
    contextMenu:
      go.GraphObject.build("ContextMenu")
        .add(
          go.GraphObject.build("ContextMenuButton", {
              click: (e, button) => {
                const node = button.part.adornedPart;
                node.removeAdornment("Segments");
              }
            })
            .bindObject("visible", "adornedPart", node => node.findAdornment("Segments") !== null)
            .add(new go.TextBlock("Discard Segments"))
        )
  })
  .add(
    new go.Shape({
        name: 'SHAPE',
        fill: 'lightgreen',
        strokeWidth: 1.5
      })
      .bindTwoWay('geometryString', 'geo')
  );

myDiagram.model = new go.GraphLinksModel([
  { geo: 'F M20 0 40 20 20 40 0 20z' },
  { geo: 'F M0 145 L75 8 100 20 120 40 131 87 160 70 180 50 195 0 L249 133z' }
]);
  </script>
</body>
</html>