Co-Study4Grid / expert_backend /services /analysis /overflow_geo_transform.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
28.5 kB
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Client-side-free repositioning of the overflow-graph HTML.
The ``expert_op4grid_recommender`` library produces an interactive
overflow HTML with graphviz's *hierarchical* (``dot``) layout. That
file contains a fully-styled SVG (colours, arrows, overloads,
tooltips, search, layer toggles — all preserved). Each node carries
a ``data-name`` attribute that matches the substation ID used in
``grid_layout.json``.
For the "Geo" toggle on the Overflow Analysis tab we want substations
placed at their geographical coordinates **without** round-tripping
through graphviz again. This module reads a hierarchical HTML,
rewrites the SVG geometry using a layout map, and returns the
transformed HTML string.
The transform is purely structural: no graphviz, no
``expert_op4grid_recommender.config`` flags, no ``env.name_sub``
alignment. Any node whose ``data-name`` is absent from the layout
map keeps its original hierarchical position — the caller gets a
mixed-mode graph instead of a 400 — and the helper logs which names
were missing so operators can fix the file.
Design notes:
* Node repositioning is done by wrapping the node group's inner
children in ``<g transform="translate(dx, -dy)">``. This avoids
having to walk every inner SVG primitive (ellipse, polygon, text,
nested paths …) and preserves their internal relationships (label
offset from circle centre, multi-shape nodes …). The SVG y-axis
grows down while graphviz (and ``data-attr-pos``) uses y-up, so the
vertical delta is negated.
* Edges are redrawn as straight lines between the new node centres.
The original ``dot`` spline routing is discarded because it is
optimised for a layered layout, not a geographic one — a straight
"as-the-crow-flies" edge is the right visual for geo anyway.
* The viewBox is preserved; layout coordinates are fitted uniformly
into it (with a small margin) so geographic aspect ratio is kept.
"""
from __future__ import annotations
import logging
import math
import re
from typing import Mapping
from lxml import etree
logger = logging.getLogger(__name__)
# Graphviz's SVG puts a top-level `scale(1 1) translate(4 -1868.2)`
# wrapper that flips the y-axis so children can use y-up positions.
# When we insert additional coordinates (edge paths, arrowheads, edge
# label text) we therefore negate y to stay consistent with the
# ellipse `cy="-Y"` convention the file already uses.
_ARROW_LEN = 14.0 # px, size of the redrawn arrowhead
_ARROW_HALF = 6.0 # px, half-width of the arrowhead base
_MARGIN = 40.0 # px, inner padding inside the viewBox when fitting
# the layout bbox
_NODE_GAP = 18.0 # px, pull the arrowhead back this much from the
# target node's centre so the tip lands on the
# node's outline, not inside it
_NODE_RADIUS_PX = 27.0 # matches the `rx` the library's graphviz
# output uses on ellipses — the default node
# width (0.75in × 72pt/in) rounded to the
# graphviz shape.
_TARGET_SPACING_RATIO = 6.0 # target average inter-node distance,
# expressed as multiples of node radius.
# Keeps edges visible with a clear tail
# and leaves room for the midpoint label.
_MIN_VIEWBOX_DIM = 600.0 # px, viewBox floor so tiny grids still
# get a usable canvas.
_MAX_VIEWBOX_DIM = 4000.0 # px, viewBox ceiling so extreme spread
# grids don't blow up the iframe.
_MIN_CONTENT_SCALE = 1.0 # never shrink visual elements below the
# library's original sizes.
_MAX_CONTENT_SCALE = 5.0 # don't let a huge canvas make labels
# dominate to the point of overlap.
def _fmt(v: float) -> str:
"""Trim trailing zeros on the SVG attribute float formatting."""
return f"{v:.3f}".rstrip("0").rstrip(".")
def _local_tag(elem) -> str:
"""Tag name without XML namespace prefix. Returns '' for
non-Element nodes (lxml exposes XML comments and PIs with a
callable `.tag` attribute)."""
tag = elem.tag
if not isinstance(tag, str):
return ""
return tag.rpartition("}")[-1] if "}" in tag else tag
def _has_class(elem, cls: str) -> bool:
raw = elem.get("class")
if not raw:
return False
return cls in raw.split()
def transform_html(html: str, layout: Mapping[str, tuple[float, float]]) -> str:
"""Return a copy of ``html`` with its SVG nodes placed at
geographical coordinates taken from ``layout`` and edges redrawn
as straight lines.
Parameters
----------
html : str
The full hierarchical overflow-graph HTML, as produced by
``make_overflow_graph_visualization`` → alphaDeesp Printer.
layout : Mapping[str, tuple[float, float]]
Substation id → (x, y) in whatever units the operator's
``grid_layout.json`` uses. The bounding box is fitted
uniformly into the existing SVG viewBox, so the absolute
scale of ``layout`` does not matter — only the relative
positions.
Returns
-------
str
Transformed HTML. Raises ``ValueError`` when the HTML does
not contain an ``<svg>`` block or no positioned nodes.
"""
svg_match = re.search(r"<svg[^>]*>.*?</svg>", html, re.DOTALL)
if not svg_match:
raise ValueError("Hierarchical overflow HTML has no <svg> block")
svg_str = svg_match.group(0)
parser = etree.XMLParser(remove_blank_text=False, recover=True)
svg_root = etree.fromstring(svg_str.encode("utf-8"), parser)
if svg_root is None:
raise ValueError("Could not parse <svg> block")
# --------------------------------------------------------------
# 1. Collect current node positions from data-attr-pos
# --------------------------------------------------------------
nodes_by_name: dict[str, etree._Element] = {}
old_positions: dict[str, tuple[float, float]] = {}
for g in svg_root.iter():
if _local_tag(g) != "g" or not _has_class(g, "node"):
continue
name = g.get("data-name")
pos_attr = g.get("data-attr-pos")
if not name or not pos_attr:
continue
try:
ox_str, oy_str = pos_attr.split(",")
ox, oy = float(ox_str), float(oy_str)
except ValueError:
continue
nodes_by_name[name] = g
old_positions[name] = (ox, oy)
if not old_positions:
raise ValueError("No positioned nodes (data-attr-pos) found in SVG")
matched = [layout[n] for n in old_positions if n in layout]
missing = [n for n in old_positions if n not in layout]
if missing:
logger.warning(
"Overflow geo transform: %d/%d HTML nodes have no layout entry "
"— they will keep their hierarchical position. Missing sample: %r.",
len(missing), len(old_positions), missing[:5],
)
if not matched:
raise ValueError(
"None of the HTML node names match entries in the layout map; "
"cannot build a geo layout."
)
# --------------------------------------------------------------
# 2. Choose scale from layout spacing + rewrite viewBox
# --------------------------------------------------------------
# The library's hierarchical HTML uses a narrow viewBox
# (optimised for graphviz's top-down tree layout). Fitting a wide
# geographic bbox into it squashes everything horizontally and the
# straight-line edges end up almost fully hidden behind the two
# node circles — only the arrowhead peeks out. We fix this by:
# (a) picking a scale that makes the average inter-substation
# distance ≈ _TARGET_SPACING_RATIO × node radius, so edges
# keep a clear tail,
# (b) rewriting the SVG viewBox to the natural aspect ratio of
# the layout bbox, and
# (c) re-anchoring the graphviz top-level
# `<g class="graph"> transform="... translate(Ox Oy)"` so
# its y-down/y-up flip lands the content inside the new
# viewBox.
min_lx = min(p[0] for p in matched)
max_lx = max(p[0] for p in matched)
min_ly = min(p[1] for p in matched)
max_ly = max(p[1] for p in matched)
span_x = max_lx - min_lx or 1.0
span_y = max_ly - min_ly or 1.0
# Capture the old viewBox BEFORE rewriting it so we can compute
# how much visual elements (text, node circles, arrows) need to
# grow to stay proportionally readable on the new, larger canvas.
old_vb = svg_root.get("viewBox", "0 0 726 1356").split()
try:
old_w, old_h = float(old_vb[2]), float(old_vb[3])
except (IndexError, ValueError):
old_w, old_h = 726.0, 1356.0
scale = _scale_for_target_spacing(matched)
# Natural viewBox size from the chosen scale.
natural_w = span_x * scale + 2 * _MARGIN
natural_h = span_y * scale + 2 * _MARGIN
# Clamp the LARGER dimension to [_MIN_VIEWBOX_DIM, _MAX_VIEWBOX_DIM]
# and rescale the other side proportionally so the geographic
# aspect ratio is preserved even at the caps.
largest = max(natural_w, natural_h)
if largest < _MIN_VIEWBOX_DIM:
boost = _MIN_VIEWBOX_DIM / largest
scale *= boost
natural_w *= boost
natural_h *= boost
elif largest > _MAX_VIEWBOX_DIM:
shrink = _MAX_VIEWBOX_DIM / largest
scale *= shrink
natural_w *= shrink
natural_h *= shrink
new_w, new_h = natural_w, natural_h
svg_root.set("viewBox", f"0 0 {_fmt(new_w)} {_fmt(new_h)}")
svg_root.set("width", f"{_fmt(new_w)}pt")
svg_root.set("height", f"{_fmt(new_h)}pt")
_reanchor_graph_transform(svg_root, new_h)
# Text scale: labels were sized for the old (small) viewBox and
# look tiny on the new (big) canvas. Scale `font-size` by
# sqrt(area ratio), clamped, so edge labels stay readable.
#
# Node circles (`rx`/`ry`) and arrows are deliberately NOT scaled
# — doing so broke the edge-spacing invariant: the target edge
# length is `_TARGET_SPACING_RATIO * _NODE_RADIUS_PX` (6 × 27 =
# 162 px). If nodes grew by 3.6× the combined radii of two
# adjacent substations (200 px) would exceed the median edge
# length, hiding edges between close pairs. Keeping graphviz-
# native node sizes lets edges between close substations remain
# clearly drawn.
text_scale = math.sqrt((new_w * new_h) / max(old_w * old_h, 1.0))
text_scale = max(_MIN_CONTENT_SCALE, min(_MAX_CONTENT_SCALE, text_scale))
# The graphviz background `<polygon fill="white" stroke="transparent">`
# still carries the original viewBox's point coordinates. After we
# re-anchored the graph-level translate it ends up drawn in the
# wrong region of the new viewBox (visible as a stray white
# rectangle). Remove it — the `#stage` div already owns the page
# background.
_remove_background_polygon(svg_root)
# Scale text labels only; leave node circles and edge strokes at
# their graphviz-native sizes so close-pair edges stay visible.
_scale_text_labels(svg_root, text_scale)
def project(lx: float, ly: float) -> tuple[float, float]:
"""Layout (x, y) → graphviz-local (x, y_up). The graph-level
transform maps this to screen via translate(MARGIN, new_h - MARGIN)
+ cy=-Y_up → screen_y = (new_h - MARGIN) - Y_up."""
return (lx - min_lx) * scale, (ly - min_ly) * scale
new_positions: dict[str, tuple[float, float]] = {}
for name, old in old_positions.items():
new_positions[name] = project(*layout[name]) if name in layout else old
# --------------------------------------------------------------
# 3. Reposition each node group
# --------------------------------------------------------------
for name, g in nodes_by_name.items():
ox, oy = old_positions[name]
nx, ny = new_positions[name]
dx, dy = nx - ox, ny - oy
if dx == 0 and dy == 0:
continue # unchanged (unmatched node)
# Wrap children in an inner translate-<g>. Graphviz y-up vs
# SVG y-down: graphviz `cy="-Y"` means "visually up" for a
# positive Y input. A positive geographic dy should therefore
# move the node visually up in the rendered SVG, which
# corresponds to a NEGATIVE translation on the svg's own y
# axis. Hence `-dy`.
children = list(g)
wrapper = etree.SubElement(g, "g")
wrapper.set("transform", f"translate({_fmt(dx)} {_fmt(-dy)})")
# Keep <title> at the top level of the node group for a11y /
# tooltip libraries that read it directly.
for child in children:
tag = _local_tag(child)
if tag == "title":
continue # leave <title> untranslated
g.remove(child)
wrapper.append(child)
# Also update data-attr-pos so re-transforming (e.g. clicking
# Geo again after a fresh Step-2) picks up the new position.
g.set("data-attr-pos", f"{_fmt(nx)},{_fmt(ny)}")
# --------------------------------------------------------------
# 4. Redraw edges as straight lines
# --------------------------------------------------------------
for g in svg_root.iter():
if _local_tag(g) != "g" or not _has_class(g, "edge"):
continue
src = g.get("data-source")
tgt = g.get("data-target")
if not src or not tgt:
continue
if src not in new_positions or tgt not in new_positions:
continue
sx, sy = new_positions[src]
tx, ty = new_positions[tgt]
# Graphviz flips y at the top of the tree: we output
# `M sx,-sy L tx,-ty` so paths render correctly.
sxn, syn = sx, -sy
txn, tyn = tx, -ty
# Size the arrowhead proportionally to the edge's own stroke
# width — graphviz emits ``stroke-width="15"`` for heavy lines
# and ``"1.5"`` for light ones. A fixed-size arrow looked
# chunky on thick lines (the default stroke inflated the tiny
# triangle into a blob) and invisible on thin ones. Scaling
# with the stroke makes every arrowhead read as a natural
# extension of its line.
path_stroke = _edge_path_stroke_width(g)
arrow_len = max(4.0, path_stroke * 3.5)
arrow_half = max(2.5, path_stroke * 1.2)
node_gap = _NODE_GAP + path_stroke * 0.5
# Pull the arrowhead slightly back so its tip lands on the
# node outline rather than the node centre.
ex, ey = _pull_back(sxn, syn, txn, tyn, node_gap)
# Tapered (style="tapered") edges represent SWAPPED-FLOW lines
# in the upstream graph builder. Graphviz emits them as TWO
# ``<polygon>`` children (a long ~21-point body + a 4-point
# arrowhead) and NO ``<path>``. The plain loop below would
# overwrite BOTH polygons with arrowhead shapes, leaving the
# edge with no visible body and no clearly directed arrow.
# Special-case the rewrite so the body becomes a tapered
# 4-vertex strip (wide at source, narrow at target — same
# visual cue as the hierarchical layout) and the second
# polygon stays the arrowhead.
is_tapered = g.get("data-attr-style") == "tapered"
polygons = [c for c in g.iter()
if _local_tag(c) == "polygon"]
if is_tapered and polygons:
_rewrite_tapered_edge(
polygons, sxn, syn, ex, ey,
stroke_w=path_stroke,
arrow_len=arrow_len,
arrow_half=arrow_half,
)
# Edge labels still need to move to the midpoint of the
# new straight line.
for child in g.iter():
if _local_tag(child) == "text":
child.set("x", _fmt((sxn + ex) / 2))
child.set("y", _fmt((syn + ey) / 2))
continue
for child in g.iter():
tag = _local_tag(child)
if tag == "path":
child.set("d", f"M{_fmt(sxn)},{_fmt(syn)} L{_fmt(ex)},{_fmt(ey)}")
elif tag == "polygon":
child.set("points", _arrowhead_points(sxn, syn, ex, ey,
arrow_len=arrow_len,
arrow_half=arrow_half))
# Zero the polygon's stroke — graphviz inherited
# `stroke-width="15"` onto the arrowhead polygon too,
# which inflates a 14×12 triangle into a ~28×27 chunky
# blob. The triangle shape from our ``points`` is all
# the arrowhead needs.
child.set("stroke-width", "0")
elif tag == "text":
# Edge label sits at midpoint.
mx = (sxn + ex) / 2
my = (syn + ey) / 2
child.set("x", _fmt(mx))
child.set("y", _fmt(my))
# --------------------------------------------------------------
# 5. Z-order: edges behind nodes
# --------------------------------------------------------------
_stack_edges_below_nodes(svg_root)
# --------------------------------------------------------------
# 5. Serialise back
# --------------------------------------------------------------
new_svg = etree.tostring(svg_root, pretty_print=False).decode("utf-8")
return html[: svg_match.start()] + new_svg + html[svg_match.end():]
def _pull_back(sx: float, sy: float, tx: float, ty: float, distance: float) -> tuple[float, float]:
"""Return a point on the segment (sx,sy)->(tx,ty) that sits
``distance`` px short of (tx,ty). If the segment is too short,
returns (tx, ty) unchanged."""
dx, dy = tx - sx, ty - sy
length = math.hypot(dx, dy)
if length <= distance or length == 0:
return tx, ty
ratio = (length - distance) / length
return sx + dx * ratio, sy + dy * ratio
def _scale_for_target_spacing(points: list[tuple[float, float]]) -> float:
"""Pick a layout-to-viewBox scale so the median nearest-neighbour
distance between points becomes ``_TARGET_SPACING_RATIO × node
radius`` pixels. That keeps edge tails visible without letting
one far-away substation blow up the overall canvas (as a bbox /
mean-distance scaling would). Falls back to 1.0 when fewer than
two points are available."""
if len(points) < 2:
return 1.0
# Nearest-neighbour distance per point, then the median over the
# whole set — O(N²) but N is the number of substations in the
# overflow graph (typically < 500, usually dozens).
nearest: list[float] = []
for i, (ax, ay) in enumerate(points):
best = float("inf")
for j, (bx, by) in enumerate(points):
if i == j:
continue
d = math.hypot(ax - bx, ay - by)
if d < best:
best = d
if best != float("inf") and best > 0:
nearest.append(best)
if not nearest:
return 1.0
nearest.sort()
median = nearest[len(nearest) // 2]
target_px = _TARGET_SPACING_RATIO * _NODE_RADIUS_PX
return target_px / median
def _reanchor_graph_transform(svg_root, new_h: float) -> None:
"""Rewrite the top-level ``<g class="graph"> transform`` so its
y-flip lands on the new viewBox. The library emits something like
``scale(1 1) rotate(0) translate(4 1352)`` where 1352 was the
original viewBox height. We replace the last translate pair with
``translate(MARGIN, new_h - MARGIN)`` so ``cy=-Y_up`` renders at
``screen_y = (new_h - MARGIN) - Y_up``, inside the padded new
viewBox."""
for g in svg_root.iter():
if _local_tag(g) != "g" or not _has_class(g, "graph"):
continue
t = g.get("transform", "")
new_translate = f"translate({_fmt(_MARGIN)} {_fmt(new_h - _MARGIN)})"
if "translate(" in t:
t = re.sub(r"translate\([^)]*\)", new_translate, t)
else:
t = f"{t} {new_translate}".strip()
g.set("transform", t)
return
def _tapered_strip_points(sx: float, sy: float, ex: float, ey: float,
width_src: float, width_tgt: float) -> str:
"""Return the ``points`` attribute for a 4-vertex polygon shaped
like a tapered strip — wide at (sx, sy), narrow at (ex, ey).
Graphviz uses ``style="tapered"`` to mark **swapped-flow** edges
in the upstream overflow graph: the line itself is a filled
polygon whose width tapers from source to target. The
hierarchical layout's tapered polygon has ~20 points hugging the
spline; for the geo redraw a simple 4-vertex strip along the
straight (sx,sy)→(ex,ey) segment carries the same visual
semantic without any spline math.
"""
dx, dy = ex - sx, ey - sy
length = math.hypot(dx, dy)
if length == 0:
return f"{_fmt(sx)},{_fmt(sy)} {_fmt(ex)},{_fmt(ey)}"
ux, uy = dx / length, dy / length
# Perpendicular unit vector (rotated 90° CCW).
px, py = -uy, ux
hs = width_src / 2.0
ht = width_tgt / 2.0
return (
f"{_fmt(sx + px * hs)},{_fmt(sy + py * hs)} "
f"{_fmt(ex + px * ht)},{_fmt(ey + py * ht)} "
f"{_fmt(ex - px * ht)},{_fmt(ey - py * ht)} "
f"{_fmt(sx - px * hs)},{_fmt(sy - py * hs)}"
)
def _rewrite_tapered_edge(polygons: list, sx: float, sy: float,
ex: float, ey: float,
stroke_w: float,
arrow_len: float,
arrow_half: float) -> None:
"""Rewrite a tapered edge's polygons in place.
The hierarchical SVG emits two polygons per tapered edge:
1. a long filled body (the tapered strip itself);
2. a short triangular arrowhead at the target.
Both are rewritten — the body becomes a 4-vertex tapered strip
along the new straight segment, and the arrowhead retains its
triangle shape pointing at the target node. ``stroke-width`` is
zeroed because graphviz inherits a heavy stroke onto these
polygons that would otherwise turn the tapered fill into a chunky
blob in the geo viewBox.
"""
body = polygons[0]
body.set(
"points",
_tapered_strip_points(
sx, sy, ex, ey,
width_src=max(stroke_w, 4.0),
width_tgt=max(stroke_w * 0.25, 1.0),
),
)
body.set("stroke-width", "0")
if len(polygons) >= 2:
arrow = polygons[1]
arrow.set(
"points",
_arrowhead_points(sx, sy, ex, ey,
arrow_len=arrow_len, arrow_half=arrow_half),
)
arrow.set("stroke-width", "0")
def _arrowhead_points(sx: float, sy: float, ex: float, ey: float,
arrow_len: float = _ARROW_LEN,
arrow_half: float = _ARROW_HALF) -> str:
"""Return the `points` attribute for a triangular arrowhead whose
tip is at (ex, ey) and whose base is perpendicular to the
(sx, sy) → (ex, ey) direction. Dimensions default to the module
constants but the caller passes scaled values in geo-mode so the
arrow stays proportional to the canvas."""
dx, dy = ex - sx, ey - sy
length = math.hypot(dx, dy)
if length == 0:
return f"{_fmt(ex)},{_fmt(ey)} {_fmt(ex)},{_fmt(ey)} {_fmt(ex)},{_fmt(ey)}"
ux, uy = dx / length, dy / length # unit along
px, py = -uy, ux # unit perpendicular
base_x = ex - ux * arrow_len
base_y = ey - uy * arrow_len
left_x = base_x + px * arrow_half
left_y = base_y + py * arrow_half
right_x = base_x - px * arrow_half
right_y = base_y - py * arrow_half
return (
f"{_fmt(ex)},{_fmt(ey)} "
f"{_fmt(left_x)},{_fmt(left_y)} "
f"{_fmt(right_x)},{_fmt(right_y)}"
)
def _remove_background_polygon(svg_root) -> None:
"""Drop the graphviz-emitted background ``<polygon fill="white"
stroke="transparent">``. Its ``points`` attribute carries the
original viewBox coordinates; after we rewrite the viewBox the
polygon lands in the wrong place and renders as a stray white
rectangle over some of the content. The parent iframe already
has its own background colour."""
for g in svg_root.iter():
if _local_tag(g) != "g" or not _has_class(g, "graph"):
continue
for child in list(g):
if _local_tag(child) != "polygon":
continue
fill = child.get("fill", "").lower()
stroke = child.get("stroke", "").lower()
if fill == "white" and stroke == "transparent":
g.remove(child)
return
def _edge_path_stroke_width(edge_g) -> float:
"""Return the stroke-width of the first ``<path>`` child of an
edge group. Graphviz encodes line capacity there — we size the
arrowhead proportionally so a thick edge gets a matching big
arrow and a thin edge a small one. Falls back to 1.5 (the default
the library uses for low-capacity lines)."""
for child in edge_g.iter():
if _local_tag(child) != "path":
continue
raw = child.get("stroke-width")
if not raw:
return 1.5
try:
return float(raw)
except ValueError:
return 1.5
return 1.5
def _stack_edges_below_nodes(svg_root) -> None:
"""Reorder the direct children of ``<g class="graph">`` so every
``<g class="edge">`` appears BEFORE every ``<g class="node">``.
SVG z-order equals document order — putting edges first means
they render behind nodes, so node circles (and the labels inside
them) stay visible even when an edge runs through a node's area.
Non-edge / non-node siblings (``<title>``, any leftover graphviz
scaffolding) keep their position at the head of the group so the
file opens identically in the library's interactive viewer."""
for g in svg_root.iter():
if _local_tag(g) != "g" or not _has_class(g, "graph"):
continue
children = list(g)
edges = [c for c in children
if _local_tag(c) == "g" and _has_class(c, "edge")]
nodes = [c for c in children
if _local_tag(c) == "g" and _has_class(c, "node")]
edge_set = set(id(e) for e in edges)
node_set = set(id(n) for n in nodes)
others = [c for c in children
if id(c) not in edge_set and id(c) not in node_set]
# Already sorted? (others | edges | nodes order). Avoid a
# needless DOM mutation when the graph comes in that way.
desired = others + edges + nodes
if desired == children:
return
for child in children:
g.remove(child)
for child in desired:
g.append(child)
return
def _scale_text_labels(svg_root, scale: float) -> None:
"""Multiply ``font-size`` on every ``<text>`` element by
``scale`` so labels stay readable on a larger geo canvas.
Node circles, edge strokes, and arrow polygons are left at their
graphviz-native sizes — scaling them up would break the
edge-spacing relationship between ``_NODE_RADIUS_PX`` and
``_TARGET_SPACING_RATIO``, causing close-pair edges to be
hidden behind enlarged node outlines."""
if scale == 1.0:
return
for elem in svg_root.iter():
if _local_tag(elem) == "text":
_scale_attr(elem, "font-size", scale)
def _scale_attr(elem, name: str, factor: float) -> None:
"""Multiply a float-valued SVG attribute by ``factor`` in place.
Silent no-op when the attribute is missing or non-numeric."""
raw = elem.get(name)
if raw is None:
return
try:
value = float(raw)
except ValueError:
return
elem.set(name, _fmt(value * factor))