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
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;" '