What behaviors might a “characteristic” have? Can the user select it? If they can select it, can they move it or copy it or delete it? If they delete it, does it delete the line/Link that it is connected with? Or is its lifetime necessarily the same as the line/Link that it’s on?
Basically are “characteristics” independent Nodes, or are they merely labels owned by the line/Link?
Comments are easy:
<!DOCTYPE html>
<html>
<head>
<title>Minimal Piping & Instrumentation Diagram</title>
<!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
</head>
<body>
<div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:400px"></div>
Use the context menu on a line/Link to change its line symbol.<br>
<input type="checkbox" onclick="allowLinking(event.target.checked)">Allow Draw Link</input><br>
<textarea id="mySavedModel" style="width:100%;height:250px"></textarea>
Design taken from <a href="https://www.aiche.org/sites/default/files/chenected/2010/08/ChEnected-Example-PIDs-and-Lead-Sheets.pdf">AIChE example diagram</a>
<script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
<script id="code">
// Produce a Geometry that includes an arrowhead at the end of each segment.
// This only works with orthogonal non-Bezier routing.
class MultiArrowLink extends go.Link {
constructor(init) {
super();
this.routing = go.Routing.Orthogonal;
if (init) Object.assign(this, init);
}
static FigureBuilders = {
"capillary":
(geo, x, y, a) => // independent of angle
geo.add(
new go.PathFigure(x-3, y-3, false)
.add(new go.PathSegment(go.SegmentType.Line, x+3, y+3))
.add(new go.PathSegment(go.SegmentType.Move, x+3, y-3))
.add(new go.PathSegment(go.SegmentType.Line, x-3, y+3))
),
"software":
(geo, x, y, a) => // independent of angle
geo.add(
new go.PathFigure(x+3, y, true)
.add(new go.PathSegment(go.SegmentType.Arc, 0, 360, x, y, 3, 3))
),
"mechanical":
(geo, x, y, a) => // independent of angle
geo.add(
new go.PathFigure(x+3, y, true)
.add(new go.PathSegment(go.SegmentType.Arc, 0, 360, x, y, 3, 3))
),
"pneumatic":
(geo, x, y, a) => {
if (a === 90 || a === 270) { // distinguish horizontal and vertical
geo.add(
new go.PathFigure(x-3, y-3, false)
.add(new go.PathSegment(go.SegmentType.Line, x+3, y))
.add(new go.PathSegment(go.SegmentType.Move, x-3, y))
.add(new go.PathSegment(go.SegmentType.Line, x+3, y+3))
);
} else {
geo.add(
new go.PathFigure(x, y-3, false)
.add(new go.PathSegment(go.SegmentType.Line, x-3, y+3))
.add(new go.PathSegment(go.SegmentType.Move, x+3, y-3))
.add(new go.PathSegment(go.SegmentType.Line, x, y+3))
);
}
},
"hydraulic":
(geo, x, y, a) => {
if (a === 90) { // L-shape must be different for each angle
geo.add(
new go.PathFigure(x+3, y-1, false)
.add(new go.PathSegment(go.SegmentType.Line, x-3, y-1))
.add(new go.PathSegment(go.SegmentType.Line, x-3, y+3))
);
} else if (a === 270) {
geo.add(
new go.PathFigure(x-3, y+1, false)
.add(new go.PathSegment(go.SegmentType.Line, x+3, y+1))
.add(new go.PathSegment(go.SegmentType.Line, x+3, y-3))
);
} else if (a === 180) {
geo.add(
new go.PathFigure(x+2, y+3, false)
.add(new go.PathSegment(go.SegmentType.Line, x+2, y-3))
.add(new go.PathSegment(go.SegmentType.Line, x-1, y-3))
);
} else {
geo.add(
new go.PathFigure(x-1, y-3, false)
.add(new go.PathSegment(go.SegmentType.Line, x-1, y+3))
.add(new go.PathSegment(go.SegmentType.Line, x+2, y+3))
);
}
},
"guided":
(geo, x, y, a) => {
if (a === 90 || a === 270) { // distinguish horizontal and vertical
geo.add(
new go.PathFigure(x, y-6, false)
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y, 3, 3, 0, true, true))
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y+6, 3, 3, 0, true, false))
);
} else {
geo.add(
new go.PathFigure(x-6, y, false)
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y, 3, 3, 0, true, true))
.add(new go.PathSegment(go.SegmentType.SvgArc, x+6, y, 3, 3, 0, true, false))
);
}
},
"unguided":
(geo, x, y, a) => {
if (a === 90 || a === 270) { // distinguish horizontal and vertical
geo.add(
new go.PathFigure(x, y-6, false)
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y, 3, 3, 0, true, true))
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y+6, 3, 3, 0, true, false))
);
} else {
geo.add(
new go.PathFigure(x-6, y, false)
.add(new go.PathSegment(go.SegmentType.SvgArc, x, y, 3, 3, 0, true, true))
.add(new go.PathSegment(go.SegmentType.SvgArc, x+6, y, 3, 3, 0, true, false))
);
}
}
};
// produce a Geometry from the Link's route
makeGeometry() {
// get the Geometry created by the standard behavior
const geo = super.makeGeometry();
if (geo.type !== go.GeometryType.Path || geo.figures.length === 0) return geo;
const mainfig = geo.figures.elt(0); // assume there's just one PathFigure
const mainsegs = mainfig.segments;
const mainnumsegs = mainsegs.length;
const hasArrowheads = this.data?.arrowheads;
if (hasArrowheads) {
const arrowLen = 8; // length for each arrowhead
const arrowWid = 3; // actually half-width of each arrowhead
let fx = mainfig.startX; let fy = mainfig.startY;
for (let i = 0; i < mainnumsegs; i++) {
const e = mainsegs.elt(i);
// assume each arrowhead is a simple triangle
const ex = e.endX; const ey = e.endY;
let bx = ex; let by = ey;
let cx = ex; let cy = ey;
if (fx < ex - arrowLen) {
bx -= arrowLen; by += arrowWid;
cx -= arrowLen; cy -= arrowWid;
} else if (fx > ex + arrowLen) {
bx += arrowLen; by += arrowWid;
cx += arrowLen; cy -= arrowWid;
} else if (fy < ey - arrowLen) {
bx -= arrowWid; by -= arrowLen;
cx += arrowWid; cy -= arrowLen;
} else if (fy > ey + arrowLen) {
bx -= arrowWid; by += arrowLen;
cx += arrowWid; cy += arrowLen;
}
geo.add(
new go.PathFigure(ex, ey, true)
.add(new go.PathSegment(go.SegmentType.Line, bx, by))
.add(new go.PathSegment(go.SegmentType.Line, cx, cy).close())
);
fx = ex; fy = ey;
}
}
const type = this.data?.type;
// maybe a function taking (geo,x,y,angle) that adds geometry for a mark at (x,y)
const mark = MultiArrowLink.FigureBuilders[type];
if (mark) {
const minLength = 20; // don't draw anything if segment is less than 20 document units long
const interval = 30; // draw every 30 document units
const offset = 10; // leave at 10 document units space between the first or last marks with the ends of the segment
let fx = mainfig.startX; let fy = mainfig.startY;
for (let i = 0; i < mainnumsegs; i++) {
const e = mainsegs.elt(i);
const ex = e.endX; const ey = e.endY;
if (fx < ex - minLength) {
const len = ex - fx;
for (let x = fx + offset + ((len - 2*offset)%interval)/2; x < ex - offset; x += interval) mark(geo, x, fy, 0);
} else if (fx > ex + minLength) {
const len = fx - ex;
for (let x = ex + offset + ((len - 2*offset)%interval)/2; x < fx - offset; x += interval) mark(geo, x, fy, 180);
} else if (fy < ey - minLength) {
const len = ey - fy;
for (let y = fy + offset + ((len - 2*offset)%interval)/2; y < ey - offset; y += interval) mark(geo, fx, y, 90);
} else if (fy > ey + minLength) {
const len = fy - ey;
for (let y = ey + offset + ((len - 2*offset)%interval)/2; y < fy - offset; y += interval) mark(geo, fx, y, 90);
}
fx = ex;
fy = ey;
}
}
return geo;
}
}
// end of MultiArrowLink class
// DIAGRAM
const myDiagram =
new go.Diagram("myDiagramDiv", {
"draggingTool.isGridSnapEnabled": true,
"draggingTool.gridSnapCellSize": new go.Size(0.5, 0.5),
"rotatingTool.snapAngleMultiple": 90,
"rotatingTool.snapAngleEpsilon": 45,
allowLink: false,
allowRelink: false,
"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();
}
}
});
function allowLinking(allow) {
myDiagram.commit(diag => {
diag.allowLink = allow;
diag.allowRelink = allow;
diag.nodes.each(n => n.port.cursor = allow ? "pointer" : "");
});
}
// NODES
function commonNodeStyle(node) {
node.type = go.Panel.Spot;
node.locationSpot = go.Spot.Center;
node.locationObjectName = "ICON";
node.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify);
}
function commonPortStyle(elt) {
elt.portId = "";
elt.fromLinkable = true;
elt.toLinkable = true;
//elt.cursor = "pointer";
}
go.Shape.defineFigureGenerator("SharedDisplay", (shp, w, h) => {
return new go.Geometry()
.add(new go.PathFigure(0, 0)
.add(new go.PathSegment(go.SegmentType.Line, w, 0))
.add(new go.PathSegment(go.SegmentType.Line, w, h))
.add(new go.PathSegment(go.SegmentType.Line, 0, h).close()))
.add(new go.PathFigure(w, h/2)
.add(new go.PathSegment(go.SegmentType.Arc, 0, 360, w/2, h/2, w/2, h/2)))
.add(new go.PathFigure(0, h/2)
.add(new go.PathSegment(go.SegmentType.Line, w, h/2)));
});
myDiagram.nodeTemplateMap.add("Instrument",
new go.Node()
.apply(commonNodeStyle)
.set({ rotatable: false }) // override commonNodeStyle
.add(
new go.Shape("Circle", {
name: "ICON",
width: 40, height: 40, fill: "white",
})
.apply(commonPortStyle)
.bind("figure", "symbol"),
new go.TextBlock({ alignment: new go.Spot(0.5, 0.33) })
.bind("text", "function"),
new go.TextBlock({ alignment: new go.Spot(0.5, 0.7) })
.bind("text", "tag"),
new go.TextBlock({ alignment: go.Spot.TopRight, visible: false })
.bind("text", "quantity")
.bind("visible", "quantity", q => !!q)
));
go.Shape.defineFigureGenerator("Butterfly", (shp, w, h) => {
return new go.Geometry()
.add(new go.PathFigure(0, 0)
.add(new go.PathSegment(go.SegmentType.Line, 0, h))
.add(new go.PathSegment(go.SegmentType.Move, w, 0))
.add(new go.PathSegment(go.SegmentType.Line, w, h)))
.add(new go.PathFigure(w*5/6, h/6)
.add(new go.PathSegment(go.SegmentType.Line, w/6, h*5/6)))
.add(new go.PathFigure(w/2, h/2)
.add(new go.PathSegment(go.SegmentType.Arc, 0, 360, w/2, h/2, 2, 2)))
});
myDiagram.nodeTemplateMap.add("Valve",
new go.Node({ rotatable: true })
.apply(commonNodeStyle)
.bindTwoWay("angle")
.add(
new go.Shape({
name: "ICON",
width: 30, height: 30, background: "white",
fromSpot: go.Spot.LeftRightSides, toSpot: go.Spot.LeftRightSides
})
.apply(commonPortStyle)
.bind("figure", "symbol")
.bind("stroke", "open", open => open === true ? "green" : (open === false ? "red" : "black")),
new go.TextBlock({ alignment: go.Spot.Top, alignmentFocus: go.Spot.Bottom })
.bind("text", "size")
.bind("angle", "angle", a => a === 180 ? 180 : 0),
new go.Panel("Vertical", { alignment: go.Spot.Bottom, alignmentFocus: go.Spot.Top })
.add(
new go.TextBlock()
.bind("text", "spec")
.bind("angle", "angle", a => a === 180 ? 180 : 0),
new go.TextBlock()
.bind("text", "tag")
.bind("angle", "angle", a => a === 180 ? 180 : 0)
)
));
myDiagram.nodeTemplateMap.add("I/O",
new go.Node({ rotatable: true })
.apply(commonNodeStyle)
.bindTwoWay("angle")
.add(
new go.Shape("TriangleRight", {
name: "ICON", width: 30, height: 30, fill: "white",
fromSpot: go.Spot.Right, toSpot: go.Spot.Left
})
.apply(commonPortStyle),
new go.TextBlock({ alignment: new go.Spot(0.3, 0.53) })
.bind("text", "type")
.bind("angle", "angle", a => a === 180 ? 180 : 0)
));
myDiagram.nodeTemplateMap.add("Comment",
new go.Node({ })
.bindTwoWay("location", "loc", go.Point.parse, go.Point.stringify)
.add(
new go.TextBlock({ stroke: "brown" })
.bind("text")
));
// LINKS
function defineLinkCMbutton(name, type) {
return go.GraphObject.build("ContextMenuButton", {
click: (e, but) => {
const link = but.part.adornedPart;
e.diagram.model.commit(m => {
m.set(link.data, "type", type);
if (link.invalidateGeometry) {
link.invalidateGeometry();
} else {
const oldskips = m.skipsUndoManager;
m.skipsUndoManager = true;
link.smoothness = 1;
link.smoothness = 0.5;
m.skipsUndoManager = oldskips;
}
});
}
})
.add(new go.TextBlock(name))
}
myDiagram.linkTemplate =
new MultiArrowLink({
routing: go.Link.Orthogonal,
relinkableFrom: true, relinkableTo: true,
reshapable: true, resegmentable: true,
contextMenu:
go.GraphObject.build("ContextMenu")
.add(
defineLinkCMbutton("major process piping", "major"),
defineLinkCMbutton("minor/instrument piping", "minor"),
defineLinkCMbutton("existing piping", "existing"),
defineLinkCMbutton("electical signal", "electrical"),
defineLinkCMbutton("capillary tubing", "capillary"),
defineLinkCMbutton("software or data link", "software"),
defineLinkCMbutton("mechanical link", "mechanical"),
defineLinkCMbutton("pneumatic signal/piping", "pneumatic"),
defineLinkCMbutton("hydraulic signal", "hydraulic"),
defineLinkCMbutton("guided wave", "guided"),
defineLinkCMbutton("unguided wave", "unguided")
)
})
.add(
new go.Shape({ strokeWidth: 1.5 })
.bind("fill", "type", t => t === "software" ? "white" : "black")
.bind("strokeWidth", "type", t => t === "major" ? 3 : 1.5)
.bind("strokeDashArray", "type", t => t === "existing" ? [20, 10] : (t === "electrical" ? [10, 3] : null))
);
myDiagram.linkTemplateMap.add("Comment",
new go.Link({ fromSpot: go.Spot.LeftRightSides, toSpot: go.Spot.None })
.add(
new go.Shape({ stroke: "brown", strokeWidth: 1.5 })
));
// MODEL
myDiagram.model = new go.GraphLinksModel(
[
{ key: 1, category: "Instrument", function: "LE", tag: "123A", quantity: 3, loc: "20.5 27.5" },
{ key: 2, category: "Instrument", symbol: "SharedDisplay", function: "ZI", tag: "101-", loc: "143 27.5" },
{ key: 3, category: "Valve", symbol: "Butterfly", size: "100", spec: "N4", tag: "HV0001", loc: "77 108", open: true },
{ key: 4, category: "I/O", type: "DI", loc: "189 149" },
{ key: 5, category: "Comment", text: "this is a comment", loc: "-101.5 133.5" }
],
[
{ from: 1, to: 2, type: "hydraulic" },
{ from: 1, to: 3, type: "guided" },
{ from: 3, to: 4, type: "electrical", arrowheads: true },
{ from: 5, to: 3, category: "Comment" }
]);
</script>
</body>
</html>