feat(rack): render SVG elevation (U1 at top, front/rear columns)
This commit is contained in:
parent
a1b889209a
commit
2fd0df1597
2 changed files with 145 additions and 0 deletions
|
|
@ -115,3 +115,123 @@ def check_overlaps(items: list[dict]) -> None:
|
||||||
f"U{uu} {f}: {name} overlaps {occupied[key]}"
|
f"U{uu} {f}: {name} overlaps {occupied[key]}"
|
||||||
)
|
)
|
||||||
occupied[key] = name
|
occupied[key] = name
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s: object) -> str:
|
||||||
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_items(items: list[dict]) -> list[dict]:
|
||||||
|
"""Deterministic order: faced items by U then hostname, 0U items last."""
|
||||||
|
return sorted(
|
||||||
|
items,
|
||||||
|
key=lambda i: (
|
||||||
|
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
|
||||||
|
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
|
||||||
|
i.get("hostname", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(rack: str, items: list[dict]) -> str:
|
||||||
|
U_H = 20
|
||||||
|
COL_W = 240
|
||||||
|
LABEL_W = 30
|
||||||
|
RAIL_W = 16
|
||||||
|
PAD = 12
|
||||||
|
GAP = 50
|
||||||
|
TITLE_H = 28
|
||||||
|
|
||||||
|
items = _sorted_items(items)
|
||||||
|
left_items = [i for i in items if i.get("rack_face") == "left"]
|
||||||
|
right_items = [i for i in items if i.get("rack_face") == "right"]
|
||||||
|
|
||||||
|
body_h = RACK_UNITS * U_H
|
||||||
|
height = PAD + TITLE_H + body_h + PAD
|
||||||
|
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
|
||||||
|
rear_x = front_x + COL_W + GAP
|
||||||
|
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
|
||||||
|
top = PAD + TITLE_H
|
||||||
|
|
||||||
|
def u_y(u: int) -> int:
|
||||||
|
# U1 at the top; U numbers increase downward.
|
||||||
|
return top + (u - 1) * U_H
|
||||||
|
|
||||||
|
p: list[str] = []
|
||||||
|
p.append(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||||
|
f'height="{height}" viewBox="0 0 {width} {height}" '
|
||||||
|
f'font-family="sans-serif" font-size="11">'
|
||||||
|
)
|
||||||
|
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
|
||||||
|
p.append(
|
||||||
|
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
|
||||||
|
f'font-weight="bold">Rack {_esc(rack)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
|
||||||
|
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
|
||||||
|
)
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
|
||||||
|
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# U numbers in the gutter left of the front column.
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
|
||||||
|
f'fill="#999">{u}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw_device(fm: dict, col_x: int) -> None:
|
||||||
|
u = fm["rack_u"]
|
||||||
|
h = fm["u_height"]
|
||||||
|
y = u_y(u)
|
||||||
|
box_h = h * U_H
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
|
||||||
|
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
|
||||||
|
f'text-anchor="middle" fill="#ffffff">'
|
||||||
|
f'{_esc(name)} ({urange})</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for fm in items:
|
||||||
|
face = fm.get("rack_face")
|
||||||
|
if face in ("front", "both"):
|
||||||
|
draw_device(fm, front_x)
|
||||||
|
if face in ("rear", "both"):
|
||||||
|
draw_device(fm, rear_x)
|
||||||
|
|
||||||
|
def draw_rail(fm: dict, x: int) -> None:
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
cx = x + RAIL_W // 2
|
||||||
|
cy = top + body_h // 2
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
|
||||||
|
f'fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
|
||||||
|
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, fm in enumerate(left_items):
|
||||||
|
draw_rail(fm, PAD + idx * RAIL_W)
|
||||||
|
for idx, fm in enumerate(right_items):
|
||||||
|
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
||||||
|
|
||||||
|
p.append("</svg>")
|
||||||
|
return "\n".join(p) + "\n"
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,28 @@ def test_overlaps_ignores_zero_u_rails():
|
||||||
item(hostname="p2", rack_face="left"),
|
item(hostname="p2", rack_face="left"),
|
||||||
]
|
]
|
||||||
gen_rack.check_overlaps(items) # no raise
|
gen_rack.check_overlaps(items) # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_has_two_columns_of_48_slots():
|
||||||
|
svg = gen_rack.render_svg("rack01", [])
|
||||||
|
# one faint slot rect per U per column (front + rear)
|
||||||
|
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert svg.rstrip().endswith("</svg>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_includes_device_label():
|
||||||
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||||
|
svg = gen_rack.render_svg("rack01", items)
|
||||||
|
assert "mf00" in svg
|
||||||
|
assert "U1" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_is_deterministic():
|
||||||
|
items = [
|
||||||
|
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
|
||||||
|
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
|
||||||
|
]
|
||||||
|
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
|
||||||
|
"rack01", list(reversed(items))
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue