Skip to content

Commit b5478af

Browse files
NotYuShengclaude
andauthored
feat(network): protocol edge-colour legend + curved parallel edges (#497) (#553)
* feat(network): protocol edge-colour legend + curved parallel edges (#497) The network graph colour-codes edges by protocol but never explained the colours, and multi-protocol pairs drew every edge as an overlapping straight line so only one colour was ever visible. Legend: add an "Edges" section to the graph legend with a line swatch + label per protocol, driven by PROTOCOL_COLORS / PROTOCOL_LABELS (buildProtocolLegend). Only protocols present in the current graph are listed; protocols sharing a colour collapse into one entry (HTTPS/TLS); an "Other" swatch appears when any edge falls back to DEFAULT_EDGE_COLOR. Shown only in protocol colour mode. Curves: register @sigma/edge-curve's EdgeCurvedArrowProgram and fan out edges that share a node pair (applyParallelEdgeCurvature) so each protocol's colour is visible; single edges between a pair stay straight. The PDF-capture overdraw mirrors the curvature with a matching quadratic so exports don't collapse back to overlapping lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(network): expand curated protocol palette + fold version variants (#497) Only ~15 protocols had colours, so a capture's long tail (SSH, FTP, SMTP, SMB, SIP, QUIC, NTP, SNMP, LDAP, RTP, …) all rendered identical grey and collapsed into one "Other" legend entry. Expand PROTOCOL_COLORS with the common, recognizable protocols, each a unique colour (the legend groups by colour). Fold variants so they inherit the base colour instead of going grey: normalizeProtocol() matches exact curated keys first, then a small PROTOCOL_ALIASES map (SSL→TLS, SIP/SDP→SIP, …), then strips a trailing version suffix (TLSv1.2→TLS, SSHv2→SSH, IGMPv3→IGMP) — so regular variants need no list. getProtocolColor and buildProtocolLegend both route through it, keeping strokes and legend in sync. Raw ethertypes / pure-numeric names stay grey by design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(network): cap legend height and make it scroll when protocol list is long (#497) The expanded protocol palette can list ~20 edge colours, which ran the legend the full height of the canvas. On-screen the legend now caps to the canvas height and scrolls (slim theme-aware scrollbar); this corner stops panning the graph, an acceptable trade for a panel. The forceLight PDF-capture render keeps full height so exports aren't clipped. The list stays present-only (unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix/guard-malformed-protocol-and-curvature-values Address PR #553 review: harden against missing/malformed values so a single bad edge can't crash the graph render. - normalizeProtocol tolerates null/undefined/non-string (returns '' -> "Other") - getCurvature rejects undefined/null/NaN maxIndex (not just <= 0) - PDF-capture overdraw checks Number.isFinite(curvature) before quadraticCurveTo Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7951c8b commit b5478af

6 files changed

Lines changed: 423 additions & 5 deletions

File tree

frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@govtechsg/sgds": "^2.3.6",
2424
"@govtechsg/sgds-react": "^2.7.7",
25+
"@sigma/edge-curve": "^3.1.0",
2526
"@sigma/node-image": "^3.0.0",
2627
"@types/html2canvas": "^0.5.35",
2728
"@xyflow/react": "^12.10.2",

frontend/src/components/network/NetworkGraph/NetworkGraph.css

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,38 @@
144144
border-color: rgba(255, 255, 255, 0.1);
145145
}
146146

147+
/*
148+
* On-screen the legend caps its height to the canvas and scrolls when the protocol list is long
149+
* (a protocol-zoo capture can list ~20 edge colours). Scrolling needs pointer events, so this
150+
* corner no longer pans the graph underneath — an acceptable trade for a legend panel. Not applied
151+
* to the forceLight (PDF-capture) render, which keeps full height so the export isn't clipped.
152+
*/
153+
.ng-legend--scroll {
154+
max-height: calc(100% - 32px);
155+
overflow-y: auto;
156+
overscroll-behavior: contain;
157+
pointer-events: auto;
158+
}
159+
160+
/* Slim, unobtrusive scrollbar that reads in both canvas themes. */
161+
.ng-legend--scroll {
162+
scrollbar-width: thin;
163+
scrollbar-color: rgba(13, 17, 23, 0.28) transparent;
164+
}
165+
.ng-legend--scroll::-webkit-scrollbar {
166+
width: 6px;
167+
}
168+
.ng-legend--scroll::-webkit-scrollbar-thumb {
169+
background: rgba(13, 17, 23, 0.28);
170+
border-radius: 3px;
171+
}
172+
[data-graph-theme='dark'] .ng-legend--scroll {
173+
scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
174+
}
175+
[data-graph-theme='dark'] .ng-legend--scroll::-webkit-scrollbar-thumb {
176+
background: rgba(255, 255, 255, 0.28);
177+
}
178+
147179
.ng-legend-item {
148180
display: flex;
149181
align-items: center;
@@ -158,6 +190,38 @@
158190
box-shadow: 0 0 4px currentColor;
159191
}
160192

193+
/* Line swatch for edge-protocol legend entries — a rule, not a dot, so it reads
194+
as a connection stroke rather than a node. */
195+
.ng-legend-line {
196+
width: 16px;
197+
height: 3px;
198+
border-radius: 2px;
199+
flex-shrink: 0;
200+
}
201+
202+
/* Separates the node section from the edge-protocol section. */
203+
.ng-legend-divider {
204+
height: 1px;
205+
margin: 3px 0;
206+
background: rgba(13, 17, 23, 0.12);
207+
}
208+
209+
[data-graph-theme='dark'] .ng-legend-divider {
210+
background: rgba(255, 255, 255, 0.1);
211+
}
212+
213+
.ng-legend-heading {
214+
font-size: 9px;
215+
font-weight: 600;
216+
letter-spacing: 0.06em;
217+
text-transform: uppercase;
218+
color: rgba(31, 35, 40, 0.55);
219+
}
220+
221+
[data-graph-theme='dark'] .ng-legend-heading {
222+
color: rgba(201, 209, 217, 0.55);
223+
}
224+
161225
.ng-legend-label {
162226
font-size: 11px;
163227
color: rgba(31, 35, 40, 0.85);

frontend/src/components/network/NetworkGraph/NetworkGraph.tsx

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import ELK from 'elkjs';
44
const ELK_WORKER_URL = `${import.meta.env.BASE_URL}elk-worker.min.js`;
55
import Graph from 'graphology';
66
import Sigma from 'sigma';
7+
import { EdgeArrowProgram } from 'sigma/rendering';
8+
import { EdgeCurvedArrowProgram, indexParallelEdgesIndex } from '@sigma/edge-curve';
79
import circular from 'graphology-layout/circular';
810
import noverlap from 'graphology-layout-noverlap';
911
import type { GraphNode, GraphEdge } from '@/features/network/types';
10-
import { getProtocolColor, NODE_TYPE_CONFIG } from '@/features/network/constants';
12+
import { getProtocolColor, NODE_TYPE_CONFIG, buildProtocolLegend, DEFAULT_EDGE_COLOR } from '@/features/network/constants';
1113
import { makeVolumeEdgeColor } from '@/utils/volumeColor';
1214
import { deviceTypeIcon, deviceTypeLabel, DEVICE_TYPES } from '@/utils/deviceType';
1315
import { useStore } from '@/store';
@@ -288,6 +290,62 @@ export function edgeBytesRange(edges: GraphEdge[]): { min: number; max: number }
288290
return { min: Number.isFinite(min) ? min : 1, max };
289291
}
290292

293+
// ---------------------------------------------------------------------------
294+
// Parallel-edge curvature
295+
// ---------------------------------------------------------------------------
296+
297+
/**
298+
* Curvature for the edge at `index` within a parallel group of `maxIndex + 1` edges. Zero-index
299+
* edges stay flat; the rest fan out symmetrically. `amplitude` keeps big groups from curving so hard
300+
* they cross each other. Mirrors the reference implementation from `@sigma/edge-curve`.
301+
*/
302+
function getCurvature(index: number, maxIndex: number): number {
303+
// `!maxIndex` also rejects undefined/null/NaN (a missing parallel-index attribute) — those would
304+
// otherwise sail past `<= 0` and produce NaN curvature.
305+
if (!maxIndex || maxIndex <= 0) return 0;
306+
if (index < 0) return -getCurvature(-index, maxIndex);
307+
const amplitude = 3.5;
308+
const maxCurvature = (amplitude * (1 - Math.exp(-maxIndex / amplitude))) / maxIndex;
309+
return (maxCurvature * index) / maxIndex;
310+
}
311+
312+
/**
313+
* When two nodes are joined by more than one edge — a different protocol/app per edge, or one in
314+
* each direction — straight strokes would draw on top of each other and hide all but one colour
315+
* (#497). Curve the parallels apart so every edge (and its protocol colour) is visible; edges with a
316+
* single connection between their endpoints stay straight. Reads the parallel-group indices that
317+
* `indexParallelEdgesIndex` writes and sets each edge's render `type` + `curvature` accordingly.
318+
*/
319+
function applyParallelEdgeCurvature(graph: Graph): void {
320+
indexParallelEdgesIndex(graph, {
321+
edgeIndexAttribute: 'parallelIndex',
322+
edgeMinIndexAttribute: 'parallelMinIndex',
323+
edgeMaxIndexAttribute: 'parallelMaxIndex',
324+
});
325+
graph.forEachEdge((edge, attrs) => {
326+
const parallelIndex = attrs.parallelIndex as number | null | undefined;
327+
const parallelMinIndex = attrs.parallelMinIndex as number | null | undefined;
328+
const parallelMaxIndex = attrs.parallelMaxIndex as number | null | undefined;
329+
330+
if (typeof parallelMinIndex === 'number') {
331+
// Group spans both directions: the middle edge stays straight, the rest curve.
332+
graph.mergeEdgeAttributes(edge, {
333+
type: parallelIndex ? 'curved' : 'arrow',
334+
curvature: getCurvature(parallelIndex as number, parallelMaxIndex as number),
335+
});
336+
} else if (typeof parallelIndex === 'number') {
337+
// Same-direction parallels: curve them all (index 0 gets 0 curvature → renders straight).
338+
graph.mergeEdgeAttributes(edge, {
339+
type: 'curved',
340+
curvature: getCurvature(parallelIndex, parallelMaxIndex as number),
341+
});
342+
} else {
343+
// The only edge between its endpoints — keep it a straight arrow.
344+
graph.setEdgeAttribute(edge, 'type', 'arrow');
345+
}
346+
});
347+
}
348+
291349
// ---------------------------------------------------------------------------
292350
// Build a graphology graph from GraphNode[] / GraphEdge[]
293351
// ---------------------------------------------------------------------------
@@ -361,6 +419,9 @@ function buildGraph(
361419
});
362420
}
363421

422+
// Curve edges that share a node pair so overlapping strokes don't hide each other's colour.
423+
applyParallelEdgeCurvature(graph);
424+
364425
return graph;
365426
}
366427

@@ -496,6 +557,17 @@ export const NetworkGraph = memo(function NetworkGraph({
496557
[hiddenNodesList]
497558
);
498559

560+
// Edge-colour legend entries for the protocols present in the current graph.
561+
// Only meaningful when edges are coloured by protocol — in volume mode the
562+
// strokes encode byte counts, not protocols, so the swatches would mislead.
563+
const protocolLegend = useMemo(
564+
() =>
565+
edgeColorMode === 'protocol'
566+
? buildProtocolLegend(edges.map(e => e.data.protocol))
567+
: { entries: [], hasUnmapped: false },
568+
[edges, edgeColorMode]
569+
);
570+
499571
const hiddenNeighbors = useMemo<GraphNode[]>(() => {
500572
if (!hoveredNode || crossEdges.length === 0) return [];
501573
const neighborIds = new Set<string>();
@@ -550,6 +622,12 @@ export const NetworkGraph = memo(function NetworkGraph({
550622
renderLabels: true,
551623
renderEdgeLabels: false,
552624
defaultEdgeType: 'arrow',
625+
// 'arrow' = straight (single edge between a pair); 'curved' = fanned-out parallels, set per
626+
// edge by applyParallelEdgeCurvature so overlapping protocol colours stay distinguishable.
627+
edgeProgramClasses: {
628+
arrow: EdgeArrowProgram,
629+
curved: EdgeCurvedArrowProgram,
630+
},
553631
labelDensity: 1,
554632
labelGridCellSize: 60,
555633
labelRenderedSizeThreshold: -Infinity, // always call drawNodeLabel at every zoom level
@@ -639,7 +717,19 @@ export const NetworkGraph = memo(function NetworkGraph({
639717
const t = sigma.graphToViewport(graph.getNodeAttributes(target) as { x: number; y: number });
640718
labelCtx.beginPath();
641719
labelCtx.moveTo(s.x, s.y);
642-
labelCtx.lineTo(t.x, t.y);
720+
// Curved parallels must bow the same way here as Sigma draws them on screen, or the PDF
721+
// would show overlapping straight lines again. Same control point Sigma uses: the
722+
// midpoint offset perpendicular to the edge by `curvature` (#497).
723+
const curvature = (attrs['curvature'] as number) ?? 0;
724+
// Number.isFinite guards against a NaN curvature (missing/malformed index) reaching
725+
// quadraticCurveTo, which would silently drop the stroke.
726+
if (attrs['type'] === 'curved' && Number.isFinite(curvature) && curvature !== 0) {
727+
const cx = (s.x + t.x) / 2 + (t.y - s.y) * curvature;
728+
const cy = (s.y + t.y) / 2 - (t.x - s.x) * curvature;
729+
labelCtx.quadraticCurveTo(cx, cy, t.x, t.y);
730+
} else {
731+
labelCtx.lineTo(t.x, t.y);
732+
}
643733
labelCtx.strokeStyle = (attrs['color'] as string) ?? '#999';
644734
labelCtx.lineWidth = Math.max(0.6, (attrs['size'] as number) ?? 1);
645735
labelCtx.globalAlpha = 0.75;
@@ -961,7 +1051,9 @@ export const NetworkGraph = memo(function NetworkGraph({
9611051
)}
9621052

9631053
{/* ── Node-type legend — data-driven, matches getNodeColor/getNodeIcon ── */}
964-
<div className="ng-legend">
1054+
{/* Scrollable on-screen so a long protocol list can't overflow the canvas; the PDF-capture
1055+
render (forceLight) keeps the full height so nothing is clipped in the export. */}
1056+
<div className={`ng-legend${forceLight ? '' : ' ng-legend--scroll'}`}>
9651057
{/* Specific service nodeTypes present in this graph */}
9661058
{Object.entries(NODE_TYPE_CONFIG)
9671059
.filter(([type]) => !GENERIC_NODE_TYPES.has(type) && type !== 'cluster' &&
@@ -998,6 +1090,26 @@ export const NetworkGraph = memo(function NetworkGraph({
9981090
<span className="ng-legend-label">Unknown</span>
9991091
</div>
10001092
)}
1093+
1094+
{/* ── Edge-protocol colours — line swatches, driven by PROTOCOL_COLORS ── */}
1095+
{(protocolLegend.entries.length > 0 || protocolLegend.hasUnmapped) && (
1096+
<>
1097+
<div className="ng-legend-divider" />
1098+
<div className="ng-legend-heading">Edges</div>
1099+
{protocolLegend.entries.map(({ color, label }) => (
1100+
<div key={color} className="ng-legend-item">
1101+
<span className="ng-legend-line" style={{ background: color }} />
1102+
<span className="ng-legend-label">{label}</span>
1103+
</div>
1104+
))}
1105+
{protocolLegend.hasUnmapped && (
1106+
<div className="ng-legend-item">
1107+
<span className="ng-legend-line" style={{ background: DEFAULT_EDGE_COLOR }} />
1108+
<span className="ng-legend-label">Other</span>
1109+
</div>
1110+
)}
1111+
</>
1112+
)}
10011113
</div>
10021114
</div>
10031115
);

0 commit comments

Comments
 (0)