Add a real VLAN legend, matching the reference diagram's style

Previously the only VLAN visual was a small 14px swatch inside the detail
table -- easy to miss, not what the user meant by 'legend'. Added a
dedicated colored block per VLAN (VLAN <id> on a color fill, name in a
matching-outline box next to it), stacked left of the existing detail
table, same layout idea as the reference diagram this was modeled on.

Also fixed a real bug this surfaced: vlan_color() picked a palette color
by hash(vid), which let two different VLANs collide on the same color
(209 and 217 both landed on green). Colors are now assigned by position
among the VLANs actually present, which can't collide as long as there
are <= 8 of them (the palette size) -- true here with room to grow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe
This commit is contained in:
dcstephenson
2026-09-15 14:02:00 -05:00
parent c9dad944e9
commit 2a7528f8d8
4 changed files with 62 additions and 16 deletions
+10 -7
View File
@@ -29,13 +29,16 @@ switches/patch panels look the part: switches get draw.io's built-in network
switch icon, patch panels get a small rack-unit look (header strip + port
ticks) rather than a plain box.
A **VLAN table** sits at the top of the diagram (VLAN / Name / Description /
Subnet / Site). As of this writing NetBox has no VLANs defined here, so it
renders as an explicit "not configured yet" placeholder rather than being
silently omitted or -- worse -- filled in with invented VLAN numbers. Add
VLANs and interface tagged/untagged assignments in NetBox and regenerate
(no code changes needed): the table populates for real, and every connector
whose port has VLAN config picks up a short suffix like `9·V10 ↔ 1·V10`.
A **VLAN legend + table** sits at the top of the diagram: a colored
"VLAN &lt;id&gt; / name" block per VLAN on the left (each color also used for
that VLAN's connector tags and table swatch), and the detail table
(VLAN / Name / Description / Subnet / Site) next to it. If NetBox has no
VLANs defined, both render as an explicit "not configured yet" placeholder
rather than being silently omitted or -- worse -- filled in with invented
VLAN numbers. Add VLANs and interface tagged/untagged assignments in
NetBox and regenerate (no code changes needed): both populate for real,
and every connector whose port has VLAN config picks up a short suffix
like `9·V214+215T ↔ 1`.
Deliberately left out: literal switch CLI config dumps -- those belong in
the switches' NetBox config-context/notes, not pasted into a topology
diagram where they'd immediately go stale.
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

+51 -8
View File
@@ -23,9 +23,14 @@ devs = {d["id"]: d for d in data["devices"]}
edges = data["edges"] # [{a, a_port, a_vlan, b, b_port, b_vlan}, ...]
vlans = data.get("vlans", []) # [{vid, name, description, prefixes, site}, ...]
VLAN_PALETTE = ["#D6603F", "#3C9D6B", "#E8A33D", "#5B7FD9", "#9A6BC9", "#3FAAB0", "#C2547E", "#7A8C3F"]
VLAN_PALETTE = ["#D6603F", "#3C9D6B", "#C2547E", "#5B7FD9", "#E8A33D", "#3FAAB0", "#9A6BC9", "#7A8C3F"]
# assigned by position among the VLANs actually present (sorted by vid, same
# order they're listed/rendered in), not a hash -- guarantees no two VLANs
# share a color as long as there are <= len(VLAN_PALETTE) of them, which a
# hash-based assignment can't promise (two vids can hash to the same slot)
_vlan_color_by_vid = {v["vid"]: VLAN_PALETTE[i % len(VLAN_PALETTE)] for i, v in enumerate(vlans)}
def vlan_color(vid):
return VLAN_PALETTE[hash(vid) % len(VLAN_PALETTE)]
return _vlan_color_by_vid.get(vid, "#9AA0AA")
def vlan_tag(pv):
"""Short connector-label suffix for a port's VLAN config, e.g. 'V10' or
@@ -137,7 +142,17 @@ VLAN_HEADER_H = 30
VLAN_TABLE_W = 900
vlan_rows = max(1, len(vlans)) # the placeholder counts as one row
VLAN_TABLE_H = VLAN_HEADER_H + vlan_rows * VLAN_ROW_H + 16
cursor_y = MARGIN + TITLE_H + LEGEND_ROW_H + VLAN_TABLE_H + 20
# a dedicated colour-coded legend (VLAN id block + name block per row),
# stacked to the left of the detail table -- this is the part that reads
# as "legend" at a glance; the table next to it carries the detail
VLAN_LEGEND_W = 260
VLAN_LEGEND_ROW_H = 42
VLAN_LEGEND_GAP = 6
VLAN_LEGEND_H = vlan_rows * (VLAN_LEGEND_ROW_H + VLAN_LEGEND_GAP) - VLAN_LEGEND_GAP
VLAN_SECTION_H = max(VLAN_TABLE_H, VLAN_LEGEND_H)
cursor_y = MARGIN + TITLE_H + LEGEND_ROW_H + VLAN_SECTION_H + 20
w_main, h_main = place_section(depth_main, yslot_main, cursor_y)
main_top = cursor_y
@@ -160,7 +175,7 @@ iso_rows = (len(iso_sorted) + ISO_COLS - 1) // ISO_COLS
w_iso = MARGIN * 2 + PAD * 2 + (ISO_COLS - 1) * COL_W + SWITCH_ICON
h_iso = PAD * 2 + LABEL_STRIP + (iso_rows - 1) * ROW_H + SWITCH_ICON + SWITCH_LABEL_H
PAGE_W = max(w_main, w_dmo, w_iso, VLAN_TABLE_W + MARGIN * 2)
PAGE_W = max(w_main, w_dmo, w_iso, VLAN_LEGEND_W + 30 + VLAN_TABLE_W + MARGIN * 2)
PAGE_H = cursor_y + h_iso + MARGIN
# ---------------- style palette ----------------
@@ -303,12 +318,40 @@ for i, (fill, stroke, label) in enumerate(legend_items):
)
lx += 260
# ---------------- VLAN legend / mapping table ----------------
# ---------------- VLAN legend (colour blocks) + mapping table ----------------
# Structure is always rendered; content depends on whether NetBox actually
# has VLANs defined yet (see export_topology.py). Nothing here is invented --
# an empty table renders as an explicit "not configured" placeholder, not as
# blank space or made-up VLAN numbers.
vt_x, vt_y = MARGIN, MARGIN + TITLE_H + LEGEND_ROW_H
# an empty legend/table renders as an explicit "not configured" placeholder,
# not as blank space or made-up VLAN numbers.
vlg_x, vlg_y = MARGIN, MARGIN + TITLE_H + LEGEND_ROW_H
vlg_id_w, vlg_name_w = 70, VLAN_LEGEND_W - 70
if not vlans:
add_cell(
f'<mxCell id="vlg-empty" value="No VLANs" style="rounded=1;arcSize=12;whiteSpace=wrap;html=1;'
f'fillColor=#F1F1F2;strokeColor=#9AA0AA;dashed=1;dashPattern=4 3;fontColor=#7C8AA6;fontSize=11;" '
f'vertex="1" parent="1"><mxGeometry x="{vlg_x}" y="{vlg_y}" width="{VLAN_LEGEND_W}" '
f'height="{VLAN_LEGEND_ROW_H}" as="geometry"/></mxCell>'
)
else:
ly2 = vlg_y
for v in vlans:
color = vlan_color(v["vid"])
add_cell(
f'<mxCell id="vlg-id-{v["vid"]}" value="VLAN&#10;{v["vid"]}" style="rounded=0;whiteSpace=wrap;'
f'html=1;fillColor={color};strokeColor=none;fontColor=#FFFFFF;fontStyle=1;fontSize=13;'
f'align=center;verticalAlign=middle;lineHeight=1.1;" vertex="1" parent="1">'
f'<mxGeometry x="{vlg_x}" y="{ly2}" width="{vlg_id_w}" height="{VLAN_LEGEND_ROW_H}" as="geometry"/></mxCell>'
)
add_cell(
f'<mxCell id="vlg-name-{v["vid"]}" value="{escape(v["name"])}" style="rounded=0;whiteSpace=wrap;'
f'html=1;fillColor=#FFFFFF;strokeColor={color};strokeWidth=2;fontColor=#1A2233;fontStyle=1;'
f'fontSize=12;align=center;verticalAlign=middle;" vertex="1" parent="1">'
f'<mxGeometry x="{vlg_x+vlg_id_w}" y="{ly2}" width="{vlg_name_w}" height="{VLAN_LEGEND_ROW_H}" '
f'as="geometry"/></mxCell>'
)
ly2 += VLAN_LEGEND_ROW_H + VLAN_LEGEND_GAP
vt_x, vt_y = MARGIN + VLAN_LEGEND_W + 30, MARGIN + TITLE_H + LEGEND_ROW_H
vt_cols = [("VLAN", 60), ("Name", 130), ("Description", 300), ("Subnet", 160), ("Site", 170)]
add_cell(
f'<mxCell id="vt-bg" value="" style="rounded=0;fillColor=#FFFFFF;strokeColor=#DDE1EA;" '