You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在GoJS中指定位置添加动态端口?

GoJS动态端口自定义位置实现方案

问题说明

现有GoJS代码可在节点左侧添加动态端口,但无法通过绑定alignment属性实现端口的特定位置控制,需调整代码实现自定义定位需求。

实现步骤

1. 调整端口容器面板类型

将左侧端口的Vertical面板改为Position面板,让每个端口可基于坐标独立定位,而非固定垂直排列。

2. 给端口数据添加位置属性

在创建新端口时,为端口数据添加pos坐标属性,后续可通过修改该属性直接调整端口位置。

3. 绑定端口的Position属性

在端口的itemTemplate中,将端口面板的position属性与端口数据的pos字段绑定,实现数据驱动的位置控制。

4. 优化布局基准

设置端口容器的alignment为go.Spot.Center,确保定位基准与节点主体垂直居中对齐。

修改后的完整代码

<!DOCTYPE html>
<html lang="en">
<body>
<script src="https://unpkg.com/gojs@2.3.7/release/go.js"></script>
<div id="allSampleContent" class="p-4 w-full">
  <script id="code">
    function init() {
      const $ = go.GraphObject.make;
      myDiagram = new go.Diagram("myDiagramDiv", {
        "undoManager.isEnabled": true
      });
      myDiagram.addDiagramListener("Modified", e => {
        const button = document.getElementById("SaveButton");
        if (button) button.disabled = !myDiagram.isModified;
        const idx = document.title.indexOf("*");
        if (myDiagram.isModified) {
          if (idx < 0) document.title += "*";
        } else {
          if (idx >= 0) document.title = document.title.slice(0, idx);
        }
      });
      function makeButton(text, action, visiblePredicate) {
        return $("ContextMenuButton", $(go.TextBlock, text), {
          click: action
        }, visiblePredicate ? new go.Binding("visible", "", (o, e) => o.diagram ? visiblePredicate(o, e) : false).ofObject() : {});
      }
      const nodeMenu = $("ContextMenu", makeButton("Add left port",
        (e, obj) => addPort("left")), );
      const portSize = new go.Size(8, 8);
      const portMenu = $("ContextMenu", makeButton("Change color",
        (e, obj) => changeColor(obj.part.adornedObject)), makeButton("Remove side ports",
        (e, obj) => removeAll(obj.part.adornedObject)));
      myDiagram.nodeTemplate = $(go.Node, "Table", {
        locationObjectName: "BODY",
        locationSpot: go.Spot.Center,
        selectionObjectName: "BODY",
        contextMenu: nodeMenu
      }, new go.Binding("location", "loc", go.Point.parse).makeTwoWay(go.Point.stringify), 
      $(go.Panel, "Auto", {
        row: 1,
        column: 1,
        name: "BODY",
        stretch: go.GraphObject.Fill
      }, $(go.Shape, "Rectangle", {
        fill: "#dbf6cb",
        stroke: null,
        strokeWidth: 0,
        minSize: new go.Size(60, 60)
      }), $(go.TextBlock, {
        margin: 10,
        textAlign: "center",
        font: "bold 14px Segoe UI,sans-serif",
        stroke: "#484848",
        editable: true
      }, new go.Binding("text", "name").makeTwoWay())), 
      // 修改:将Vertical面板改为Position面板,支持自由定位
      $(go.Panel, "Position", new go.Binding("itemArray", "leftArray"), {
        row: 1,
        column: 0,
        alignment: go.Spot.Center
      }, 
      $(go.Panel, {
        _side: "left",
        fromSpot: go.Spot.Left,
        toSpot: go.Spot.Left,
        fromLinkable: true,
        toLinkable: true,
        cursor: "pointer",
        contextMenu: portMenu
      }, 
      new go.Binding("portId", "portId"),
      // 修改:绑定position属性到端口数据的pos字段
      new go.Binding("position", "pos", go.Point.parse),
      $(go.Shape, "Rectangle", {
        stroke: null,
        strokeWidth: 0,
        desiredSize: portSize,
        margin: new go.Margin(0)
      }, new go.Binding("fill", "portColor")))), 
      );
      myDiagram.linkTemplate = $(CustomLink, {
        routing: go.Link.AvoidsNodes,
        corner: 4,
        curve: go.Link.JumpGap,
        reshapable: true,
        resegmentable: true,
        relinkableFrom: true,
        relinkableTo: true
      }, new go.Binding("points").makeTwoWay(), $(go.Shape, {
        stroke: "#2F4F4F",
        strokeWidth: 2
      }));
      myDiagram.toolManager.clickCreatingTool.archetypeNodeData = {
        name: "Unit",
        leftArray: [],
        rightArray: [],
        topArray: [],
        bottomArray: []
      };
      myDiagram.contextMenu = $("ContextMenu", makeButton("Paste",
        (e, obj) => e.diagram.commandHandler.pasteSelection(e.diagram.toolManager.contextMenuTool.mouseDownPoint), o => o.diagram.commandHandler.canPasteSelection(o.diagram.toolManager.contextMenuTool.mouseDownPoint)), makeButton("Undo",
        (e, obj) => e.diagram.commandHandler.undo(), o => o.diagram.commandHandler.canUndo()), makeButton("Redo",
        (e, obj) => e.diagram.commandHandler.redo(), o => o.diagram.commandHandler.canRedo()));
      load();
    }
    class CustomLink extends go.Link {
      findSidePortIndexAndCount(node, port) {
        const nodedata = node.data;
        if (nodedata !== null) {
          const portdata = port.data;
          const side = port._side;
          const arr = nodedata[side + "Array"];
          const len = arr.length;
          for (let i = 0; i < len; i++) {
            if (arr[i] === portdata) return [i, len];
          }
        }
        return [-1, len];
      }
      computeEndSegmentLength(node, port, spot, from) {
        const esl = super.computeEndSegmentLength(node, port, spot, from);
        const other = this.getOtherPort(port);
        if (port !== null && other !== null) {
          const thispt = port.getDocumentPoint(this.computeSpot(from));
          const otherpt = other.getDocumentPoint(this.computeSpot(!from));
          if (Math.abs(thispt.x - otherpt.x) > 20 || Math.abs(thispt.y - otherpt.y) > 20) {
            const info = this.findSidePortIndexAndCount(node, port);
            const idx = info[0];
            const count = info[1];
            if (port._side == "top" || port._side == "bottom") {
              if (otherpt.x < thispt.x) {
                return esl + 4 + idx * 8;
              } else {
                return esl + (count - idx - 1) * 8;
              }
            } else { // left or right
              if (otherpt.y < thispt.y) {
                return esl + 4 + idx * 8;
              } else {
                return esl + (count - idx - 1) * 8;
              }
            }
          }
        }
        return esl;
      }
      hasCurviness() {
        if (isNaN(this.curviness)) return true;
        return super.hasCurviness();
      }
      computeCurviness() {
        if (isNaN(this.curviness)) {
          const fromnode = this.fromNode;
          const fromport = this.fromPort;
          const fromspot = this.computeSpot(true);
          const frompt = fromport.getDocumentPoint(fromspot);
          const tonode = this.toNode;
          const toport = this.toPort;
          const tospot = this.computeSpot(false);
          const topt = toport.getDocumentPoint(tospot);
          if (Math.abs(frompt.x - topt.x) > 20 || Math.abs(frompt.y - topt.y) > 20) {
            if ((fromspot.equals(go.Spot.Left) || fromspot.equals(go.Spot.Right)) && (tospot.equals(go.Spot.Left) || tospot.equals(go.Spot.Right))) {
              const fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
              const toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
              const c = (fromseglen - toseglen) / 2;
              if (frompt.x + fromseglen >= topt.x - toseglen) {
                if (frompt.y < topt.y) return c;
                if (frompt.y > topt.y) return -c;
              }
            } else if ((fromspot.equals(go.Spot.Top) || fromspot.equals(go.Spot.Bottom)) && (tospot.equals(go.Spot.Top) || tospot.equals(go.Spot.Bottom))) {
              const fromseglen = this.computeEndSegmentLength(fromnode, fromport, fromspot, true);
              const toseglen = this.computeEndSegmentLength(tonode, toport, tospot, false);
              const c = (fromseglen - toseglen) / 2;
              if (frompt.x + fromseglen >= topt.x - toseglen) {
                if (frompt.y < topt.y) return c;
                if (frompt.y > topt.y) return -c;
              }
            }
          }
        }
        return super.computeCurviness();
      }
    }
    function addPort(side) {
      myDiagram.startTransaction("addPort");
      myDiagram.selection.each(node => {
        if (!(node instanceof go.Node)) return;
        let i = 0;
        while (node.findPort(side + i.toString()) !== node) i++;
        const name = side + i.toString();
        const arr = node.data[side + "Array"];
        if (arr) {
          // 修改:新增pos属性,示例循环使用上/中/下三个位置
          const yPos = [-20, 0, 20][i % 3];
          const newportdata = {
            portId: name,
            portColor: getPortColor(),
            pos: `0 ${yPos}`
          };
          myDiagram.model.insertArrayItem(arr, -1, newportdata);
        }
      });
      myDiagram.commitTransaction("addPort");
    }
    function removeAll(port) {
      myDiagram.startTransaction("removePorts");
      const nodedata = port.part.data;
      const side = port._side;
      myDiagram.model.setDataProperty(nodedata, side + "Array", []);
      myDiagram.commitTransaction("removePorts");
    }
    function changeColor(port) {
      myDiagram.startTransaction("colorPort");
      const data = port.data;
      myDiagram.model.setDataProperty(data, "portColor", getPortColor());
      myDiagram.commitTransaction("colorPort");
    }
    function getPortColor() {
      const portColors = ["#fae3d7", "#d6effc", "#ebe3fc", "#eaeef8", "#fadfe5", "#6cafdb", "#66d6d1"]
      return portColors[Math.floor(Math.random() * portColors.length)];
    }
    function save() {
      document.getElementById("mySavedModel").value = myDiagram.model.toJson();
      myDiagram.isModified = false;
    }
    function load() {
      myDiagram.model = go.Model.fromJson(document.getElementById("mySavedModel").value);
    }
    window.addEventListener('DOMContentLoaded', init);
  </script>
  <div id="sample">
    <div id="myDiagramDiv" style="width: 600px; height: 500px; border: 1px solid black; position: relative; -webkit-tap-highlight-color: rgba(255, 255, 255, 0); cursor: auto;">
      <canvas tabindex="0" width="898" height="748" style="position: absolute; top: 0px; left: 0px; z-index: 2; user-select: none; touch-action: none; letter-spacing: normal; word-spacing: normal; width: 599px; height: 499px; cursor: auto;">This text is displayed if your browser does not support the Canvas HTML element.</canvas>
      <div style="position: absolute; overflow: auto; width: 599px; height: 499px; z-index: 1;">
        <div style="position: absolute; width: 1px; height: 1px;"></div>
      </div>
    </div>
    Add port to selected nodes: <button onclick="addPort('left')">Left</button>
    <div>
      <div>
        <button id="SaveButton" onclick="save()" disabled="">Save</button>
        <button onclick="load()">Load</button>
        Diagram Model saved in JSON format:
      </div>
      <textarea id="mySavedModel" style="width:100%;height:250px">
{ "class": "GraphLinksModel",  
  "copiesArrays": true,  
  "copiesArrayObjects": true,  
  "linkFromPortIdProperty": "fromPort",  
  "linkToPortIdProperty": "toPort",  
  "nodeDataArray": [{"key":1,"name":"Unit One","loc":"101 204","leftArray":[{"portColor":"#fae3d7","portId":"left0","pos":"0 0"}],"topArray":[],"bottomArray":[],"rightArray":[]}],  
  "linkDataArray": []}
      </textarea>
    </div>
  </div>
</body>
</html>

关键修改说明

  • 端口容器从Vertical改为Position,解除固定排列限制
  • 新增端口数据的pos坐标属性,示例中循环分配上/中/下三个位置
  • 绑定端口面板的position到pos字段,实现数据驱动定位
  • 设置端口容器对齐方式为居中,确保定位基准与节点主体匹配

内容的提问来源于stack exchange,提问作者Prem

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 13:47:01