Cable routing
Loading…
Source code
$ npx @flitterjs/cli@latest add flow-diagramimport {
Alignment,
Border,
BorderRadius,
BoxDecoration,
Center,
Column,
Container,
CrossAxisAlignment,
EdgeInsets,
Expanded,
GestureDetector,
LayoutBuilder,
MainAxisSize,
Row,
SizedBox,
Stack,
StackFit,
State,
StatefulWidget,
Text,
TextStyle,
type Widget,
} from "flitterjs";
import {
FlowDiagramController,
type FlowEdge,
type FlowNode,
} from "@flitterjs/diagram";
import FlowDiagram, {
ShapePaint,
defaultEdge,
rectPathD,
} from "@/components/diagram/flow-diagram";
type Theme = "light" | "dark";
class Routing extends StatefulWidget {
constructor(readonly theme: Theme) {
super(theme);
}
createState() {
return new RoutingState();
}
}
class RoutingState extends State<Routing> {
flow = new FlowDiagramController();
diagram?: Widget;
nodes: FlowNode[] = [
["camera", "Camera", "HDMI", "video", 0, 40, "source", "#e9a44d"],
["microphone", "Microphone", "XLR", "audio", 0, 200, "source", "#44baa6"],
["console", "Console", "HDMI", "video", 0, 370, "source", "#a58aeb"],
["monitor", "Monitor", "HDMI", "video", 680, 40, "target", "#e9a44d"],
["recorder", "Recorder", "XLR", "audio", 680, 200, "target", "#44baa6"],
["encoder", "Encoder", "HDMI", "video", 680, 370, "target", "#a58aeb"],
].map(([id, label, port, signal, x, y, kind, color]) => ({
id: String(id),
type: String(kind),
position: { x: Number(x), y: Number(y) },
width: 150,
height: 70,
data: { label, port, signal, color },
}));
edges: FlowEdge[] = [
{
id: "video",
source: "camera",
target: "monitor",
selected: true,
data: { color: "#e9a44d" },
waypoints: [
{ x: 245, y: 75 },
{ x: 245, y: 15 },
{ x: 610, y: 15 },
{ x: 610, y: 75 },
],
},
{
id: "audio",
source: "microphone",
target: "recorder",
data: { color: "#44baa6" },
waypoints: [
{ x: 220, y: 235 },
{ x: 220, y: 355 },
{ x: 610, y: 355 },
{ x: 610, y: 235 },
],
},
{
id: "console-video",
source: "console",
target: "encoder",
data: { color: "#a58aeb" },
waypoints: [
{ x: 190, y: 405 },
{ x: 190, y: 470 },
{ x: 650, y: 470 },
{ x: 650, y: 405 },
],
},
].map((edge) => ({ ...edge, type: "step", adjusting: "ends" }));
constructor() {
super();
this.nodes.unshift({
id: "rack",
type: "obstacle",
position: { x: 300, y: 125 },
width: 220,
height: 200,
data: { label: "Equipment\nrack" },
draggable: false,
selectable: false,
zIndex: -1,
});
}
get c() {
return this.widget.theme === "dark"
? {
bg: "#101b24",
card: "#1d303c",
ink: "#e8f0f6",
mute: "#99afbd",
line: "#426071",
}
: {
bg: "#f4f8fb",
card: "#e4edf3",
ink: "#253d4b",
mute: "#617c8e",
line: "#9eb5c4",
};
}
text(value: string, size = 16, color = this.c.ink) {
return Text(value, {
style: new TextStyle({ fontFamily: "system-ui", fontSize: size, color }),
});
}
button(label: string, action: () => void, active = false) {
return GestureDetector({
onMouseDown: (e) => e.stopPropagation(),
onClick: (e) => {
e.stopPropagation();
action();
this.setState();
},
child: Container({
padding: EdgeInsets.symmetric({ horizontal: 12, vertical: 10 }),
decoration: new BoxDecoration({
color: active ? this.c.card : this.c.bg,
borderRadius: BorderRadius.circular(6),
}),
child: this.text(label, 14),
}),
});
}
route(type: "step" | "bezier") {
this.flow.history.mark("Change cable routing");
this.flow.run(
() =>
this.flow.setEdges((edges) => edges.map((edge) => ({ ...edge, type }))),
{ history: "merge" },
);
}
clearBends() {
this.flow.history.mark("Clear cable bends");
this.flow.run(
() =>
this.flow.setEdges((edges) =>
edges.map((edge) => ({ ...edge, waypoints: [] })),
),
{ history: "merge" },
);
}
createDiagram(): Widget {
const c = this.c;
return FlowDiagram({
controller: this.flow,
data: { nodes: this.nodes, edges: this.edges },
viewport: { minZoom: 0.15, fitView: { padding: 0.14, maxZoom: 1 } },
interaction: {
edit: { waypoints: true },
connect: {
reconnect: true,
reconnectRadius: 18,
validate: (connection) =>
this.flow.getNode(connection.source)?.data.signal ===
this.flow.getNode(connection.target)?.data.signal,
},
drag: { snap: { grid: [10, 10] } },
},
config: {
colorMode: this.widget.theme,
colors: { background: c.bg, edgeStroke: c.line },
edge: { strokeWidth: 4 },
minimap: { visible: false },
controls: { showInteractive: false },
},
nodeOptions: {
source: {
handles: [
{ type: "source", position: "right", width: 14, height: 14 },
],
},
target: {
handles: [
{ type: "target", position: "left", width: 14, height: 14 },
],
},
obstacle: { handles: [] },
},
custom: {
node: ({ node, isSelected }) =>
node.type === "obstacle"
? Stack({
fit: StackFit.expand,
children: [
ShapePaint({
width: 220,
height: 200,
shapes: {
body: {
d: rectPathD(2, 2, 216, 196, 10),
fill: c.card,
stroke: c.line,
strokeWidth: 1.5,
dash: [7, 7],
},
},
}),
Center({ child: this.text("Equipment\nrack", 20, c.mute) }),
],
})
: Container({
padding: EdgeInsets.symmetric({ horizontal: 16, vertical: 10 }),
alignment: Alignment.centerLeft,
decoration: new BoxDecoration({
color: c.card,
borderRadius: BorderRadius.circular(35),
border: Border.all({
color: isSelected ? String(node.data.color) : c.line,
width: isSelected ? 2 : 1,
}),
}),
child: Column({
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
this.text(String(node.data.label), 18),
SizedBox({ height: 5 }),
this.text(String(node.data.port), 13, c.mute),
],
}),
}),
handle: ({ node, isConnectionCandidate, isValidCandidate }) =>
Container({
width: 14,
height: 14,
decoration: new BoxDecoration({
shape: "circle",
color:
isConnectionCandidate && !isValidCandidate
? "#ef4444"
: String(node.data.color),
border: Border.all({ color: c.bg, width: 2 }),
}),
}),
edge: (args, context) =>
defaultEdge(
{
...args,
edge: {
...args.edge,
style: {
stroke: String(args.edge.data?.color ?? c.line),
strokeWidth: args.isSelected ? 5 : 3,
},
},
},
context,
),
edgeHandle: ({ edge, kind, isActive }) =>
ShapePaint({
width: 14,
height: 14,
shapes: {
grip: {
d:
kind === "waypoint"
? "M7 1L13 7L7 13L1 7Z"
: "M2 7A5 5 0 1 0 12 7A5 5 0 1 0 2 7",
fill:
isActive || kind === "waypoint"
? String(edge.data?.color ?? c.ink)
: c.bg,
stroke: String(edge.data?.color ?? c.ink),
strokeWidth: 2,
},
},
}),
reconnectHandle: ({ edge, size }) =>
SizedBox({
width: size,
height: size,
child: Center({
child: Container({
width: 26,
height: 26,
decoration: new BoxDecoration({
shape: "circle",
border: Border.all({
color: edge.selected
? String(edge.data?.color ?? c.line)
: "transparent",
width: 2,
}),
}),
}),
}),
}),
controls: ({ zoomIn, zoomOut, fitView }) =>
Row({
mainAxisSize: MainAxisSize.min,
children: [zoomOut, zoomIn, fitView],
}),
},
});
}
build(): Widget {
const routing = this.flow.edges[0]?.type ?? "step";
return LayoutBuilder({
builder: (_context, size) =>
Column({
children: [
Container({
height: 54,
color: this.c.bg,
padding: EdgeInsets.symmetric({ horizontal: 12 }),
child: Row({
children: [
...(size.maxWidth > 620
? [Expanded({ child: this.text("Cable routing", 18) })]
: []),
this.button(
"Elbow",
() => this.route("step"),
routing === "step",
),
this.button(
"Curve",
() => this.route("bezier"),
routing === "bezier",
),
this.button("Clear bends", () => this.clearBends()),
this.button("Undo", () => this.flow.history.undo()),
],
}),
}),
Expanded({ child: (this.diagram ??= this.createDiagram()) }),
],
}),
});
}
dispose() {
this.flow.dispose();
super.dispose();
}
}
export default function EdgeRouting(theme: Theme = "light"): Widget {
return new Routing(theme);
}Edges are the main editing surface: colored paths, visible reconnection rings, distinct bend/midpoint widgets, and port-type validation. Waypoints belong to the editable graph; the rack is a visual obstacle, not an automatic routing solver.
Select a cable. Drag its diamond grips to move bends, or a hollow midpoint to add one. Double-click a bend to remove it. Drag an endpoint ring to a compatible port, switch Elbow/Curve, and undo.
Demo data; execution and deployment are simulated.