Source code for devkit_ui.utils.topo_renderer

import html
import math
from collections.abc import Iterator

from nicegui import ui

from devkit_ui.models import TopoDoc, TopoNode

_SVG_W, _SVG_H = 900, 480
_MARGIN, _NODE_R = 56, 17


def _node_transform(nodes: Iterator[TopoNode]):
    """World->screen transform (tx, ty) + extents (dx, dy) for the node bbox.

    Shared by build_svg and build_robot_svg so both layers map coordinates
    identically.
    """
    xs, ys = [], []
    for node in nodes:
        xs.append(node.x)
        ys.append(node.y)
    dx = (max(xs) - min(xs)) or 1.0
    dy = (max(ys) - min(ys)) or 1.0
    x_min, y_min = min(xs), min(ys)

    def tx(x):
        return _MARGIN + (x - x_min) / dx * (_SVG_W - 2 * _MARGIN)
    def ty(y):
        return _SVG_H - _MARGIN - (y - y_min) / dy * (_SVG_H - 2 * _MARGIN)
    return tx, ty, dx, dy


[docs] def build_robot_svg(nodes: Iterator[TopoNode], robot: tuple | None) -> str: """Transparent overlay SVG with only the live robot marker (map frame). Kept separate from build_svg so robot movement updates this layer alone, never replacing the clickable node DOM (which would drop its click handlers). """ nodes = list(nodes) empty = (f'<svg width="100%" viewBox="0 0 {_SVG_W} {_SVG_H}" ' f'xmlns="http://www.w3.org/2000/svg"></svg>') if not nodes or robot is None: return empty tx, ty, dx, dy = _node_transform(nodes) rx, ry, ryaw = robot mx, my = tx(rx), ty(ry) al = 0.07 * max(dx, dy) ex, ey = tx(rx + al * math.cos(ryaw)), ty(ry + al * math.sin(ryaw)) return (f'<svg width="100%" viewBox="0 0 {_SVG_W} {_SVG_H}" ' f'xmlns="http://www.w3.org/2000/svg">' f'<line x1="{mx:.1f}" y1="{my:.1f}" x2="{ex:.1f}" y2="{ey:.1f}" ' f'stroke="#cf222e" stroke-width="3" stroke-linecap="round"/>' f'<circle cx="{mx:.1f}" cy="{my:.1f}" r="7" fill="#cf222e" ' f'stroke="#ffffff" stroke-width="2"/></svg>')
[docs] def build_svg(doc: TopoDoc, selected: str | None, current: str | None) -> str: if not doc.nodes: return (f'<svg width="100%" viewBox="0 0 {_SVG_W} {_SVG_H}" ' f'style="background:#f6f8fa;border-radius:4px;border:1px solid #d0d7de">' f'<text x="{_SVG_W//2}" y="{_SVG_H//2}" text-anchor="middle" ' f'fill="#8c959f" font-family="Courier New" font-size="13">No map loaded</text>' f'</svg>') tx, ty, _dx, _dy = _node_transform(doc.nodes) parts: list[str] = [f'<rect width="{_SVG_W}" height="{_SVG_H}" fill="#f6f8fa" rx="4"/>'] drawn: set = set() for nd in doc.nodes: for tgt in nd.edges: tgt_name = tgt.node key = tuple(sorted([nd.name, tgt_name])) if key in drawn or not doc.has_node(tgt_name): continue drawn.add(key) tgt_node = doc.get_node(tgt_name) is_row_edge = (nd.meta.get('row_id') is not None or tgt_node.meta.get('row_id') is not None) parts.append( f'<line x1="{tx(nd.x):.1f}" y1="{ty(nd.y):.1f}" ' f'x2="{tx(tgt_node.x):.1f}" y2="{ty(tgt_node.y):.1f}" ' f'stroke="{"#d8e8fd" if is_row_edge else "#d0d7de"}" ' f'stroke-width="{"2" if is_row_edge else "1"}" stroke-linecap="round"/>') for nd in doc.nodes: name = nd.name cx, cy = tx(nd.x), ty(nd.y) is_cur = name == current is_sel = name == selected is_row = nd.meta.get('row_id') is not None # pylint: disable=multiple-statements if is_cur: fill, stroke, sw = '#dafbe1', '#1a7f37', 2 elif is_sel: fill, stroke, sw = '#fff8c5', '#9a6700', 2 elif is_row: fill, stroke, sw = '#d8e8fd', '#0969da', 1 elif nd.meta.get('dropped_by'): fill, stroke, sw = '#f6f8fa', '#8c959f', 1 else: fill, stroke, sw = '#ffffff', '#d0d7de', 1 # pylint: enable=multiple-statements label_col = ('#1a7f37' if is_cur else '#9a6700' if is_sel else '#0969da' if is_row else '#57606a') if is_cur: parts.append( f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{_NODE_R+6}" ' f'fill="none" stroke="#1a7f37" stroke-width="1" opacity="0.4">' f'<animate attributeName="r" values="{_NODE_R+6};{_NODE_R+13}" ' f'dur="2s" repeatCount="indefinite"/>' f'<animate attributeName="opacity" values="0.4;0" ' f'dur="2s" repeatCount="indefinite"/></circle>') parts.append( f'<circle class="topo-node" data-node="{html.escape(str(name), quote=True)}" ' f'cx="{cx:.1f}" cy="{cy:.1f}" r="{_NODE_R}" ' f'fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>') if is_row: role = str(nd.meta.get('row_role') or '?') row_id = str(nd.meta.get('row_id', '')) # role[0].upper() alone collides: 'entry' and 'exit' both start # with 'e', so IN/OUT nodes would render identically. Map the # roles that actually occur to distinct glyphs matching the # _IN/_OUT/_W{k} name suffixes; fall back to the first letter # for any other role string. glyph = {'entry': 'I', 'exit': 'O', 'waypoint': 'W'}.get( role, role[0].upper() if role else '?') label_text = f"{glyph}{row_id}" escaped_text = html.escape(label_text, quote=True) parts.append( f'<text x="{cx:.1f}" y="{cy+4:.1f}" text-anchor="middle" fill="#0969da" ' f'font-family="Courier New" font-size="8" font-weight="700" ' f'style="pointer-events:none">' f'{escaped_text}' f'</text>') parts.append( f'<text x="{cx:.1f}" y="{cy+_NODE_R+12:.1f}" text-anchor="middle" ' f'fill="{label_col}" font-family="Courier New" font-size="9" ' f'style="pointer-events:none">{html.escape(str(name), quote=True)}</text>') return (f'<svg id="topo-map" width="100%" viewBox="0 0 {_SVG_W} {_SVG_H}" ' f'xmlns="http://www.w3.org/2000/svg">{"".join(parts)}</svg>')
[docs] def inject_click_js() -> None: # Event delegation: a single document-level listener (attached once, # guarded) that resolves clicks to the nearest .topo-node. Survives # map redraws — unlike per-element onclick, which gets wiped every # time the SVG is rebuilt (on current-node change), leaving the # nodes unclickable. ui.run_javascript(""" if (!window.__topoDelegated) { window.__topoDelegated = true; document.addEventListener('click', (e) => { const el = e.target.closest('.topo-node'); if (el) emitEvent('topo_node_clicked', {node: el.dataset.node}); }); } """)