Source code for optiwindnet.svg

# SPDX-License-Identifier: MIT
# https://gitlab.windenergy.dtu.dk/TOPFARM/OptiWindNet/

from collections import defaultdict
from itertools import chain
from typing import Any

import networkx as nx
import numpy as np
import svg

from .geometric import rotate
from .interarraylib import describe_G
from .themes import Colors

__all__ = ('SvgRepr', 'svgplot', 'svgpplot')

_NODE_RADII = 12, 20
_RING_RADII = 23, 28
_BORDER_WIDTH = 2
_LINK_WIDTH = 4
_LINK_TYPE_WIDTH_STEP = 3
_NODE_EDGE_WIDTH = 2
_DETOUR_RING_WIDTH = 4


[docs] class SvgRepr: """ Helper class to get IPython to display the SVG figure encoded in data. """ def __init__(self, data: str, metadata: dict[str, Any] | None = None):
[docs] self.data = data
[docs] self.metadata: dict[str, Any] = metadata or {}
def _repr_svg_(self) -> str: return self.data
[docs] def __repr__(self) -> str: m = self.metadata parts = [f'SvgRepr {m["handle"]!r}'] if 'handle' in m else ['SvgRepr'] name = m.get('name') if name and name != m.get('handle'): parts.append(f'name={name!r}') if 'T' in m: parts.append(f'T={m["T"]}') if 'R' in m: parts.append(f'R={m["R"]}') if m.get('capacity') is not None: parts.append(f'capacity={m["capacity"]}') parts.append(f'{len(self.data)} chars') return '<' + ' '.join(parts) + '>'
[docs] def save(self, filepath: str) -> None: """write SVG to file `filepath`""" with open(filepath, 'w', encoding='utf-8') as file: file.write(self.data)
class Drawable: """ SVG generator for NetworkX's Graph. """ margin: int = 30 borderE: list[svg.Element] reusableE: list[svg.Element] edgesE: list[svg.Element] detoursE: list[svg.Element] nodesE: list[svg.Element] infoboxE: list[svg.Element] toplevelE: list[svg.Element] metadata: dict[str, Any] def __init__( self, G: nx.Graph, *, landscape: bool = True, dark: bool | None = None, transparent: bool = True, legend: bool = False, ): self.legend = legend self.effective_node_radius = 12 self.borderE = [] self.reusableE = [] self.edgesE = [] self.detoursE = [] self.nodesE = [] self.infoboxE = [] self.toplevelE = [] self.G, self.landscape = G, landscape R, T, B = (G.graph[k] for k in 'RTB') self.R, self.T, self.B = R, T, B name = G.graph.get('name') handle = G.graph.get('handle', name if name is not None else 'handleless') self.handle = handle self.metadata = {'handle': handle, 'T': T, 'R': R} if name is not None: self.metadata['name'] = name capacity = G.graph.get('capacity') if capacity is not None: self.metadata['capacity'] = capacity self.c = c = Colors(dark) fnT = G.graph.get('fnT') if fnT is None: fnT = np.arange(R + T + B + 3) fnT[-R:] = range(-R, 0) self.fnT = fnT ############################## # Coordinates transformation # ############################## G = self.G w, h = 1920, 1080 margin = self.margin # TODO: ¿use SVG's attr overflow="visible" instead of margin? VertexC = G.graph['VertexC'] landscape_angle = G.graph.get('landscape_angle', False) if self.landscape and landscape_angle: # landscape_angle is not None and not 0 VertexC = rotate(VertexC, landscape_angle) # viewport scaling idx_B = self.T + self.B R = self.R Woff = min(VertexC[:idx_B, 0].min(), VertexC[-R:, 0].min()) W = max(VertexC[:idx_B, 0].max(), VertexC[-R:, 0].max()) - Woff Hoff = min(VertexC[:idx_B, 1].min(), VertexC[-R:, 1].min()) H = max(VertexC[:idx_B, 1].max(), VertexC[-R:, 1].max()) - Hoff wr = (w - 2 * margin) / W hr = (h - 2 * margin) / H if W / H < w / h: # tall aspect scale = hr else: # wide aspect scale = wr h = round(H * scale + 2 * margin) offset = np.array((Woff, Hoff)) VertexS = (VertexC - offset) * scale + margin # y axis flipping VertexS[:, 1] = h - VertexS[:, 1] VertexS = VertexS.round().astype(int) self.VertexS = VertexS self.bottom_right_anchor = dict(x=round(W * scale + margin), y=h - margin) self.h_orig = h if self.legend: h = h + 80 self.viewBox = svg.ViewBoxSpec(0, 0, w, h) self.w, self.h = w, h self.overflow = None # set to 'hidden' by add_edges() if needed ####################### # Background elements # ####################### if not transparent: # draw an opaque canvas the same size as the viewport self.toplevelE.append(svg.Rect(fill=c.bg_color, width=w, height=h)) border, obstacles, landscape_angle = ( G.graph.get(k) for k in 'border obstacles landscape_angle'.split() ) # prepare obstacles draw_obstacles = [] if obstacles is not None: for obstacle in obstacles: draw_obstacles.append( 'M' + ' '.join(str(c) for c in VertexS[obstacle].flat) + 'z' ) if border is not None: # border with obstacles as holes self.borderE.append( svg.Path( id='border', stroke=c.kind2color['border'], stroke_dasharray=[15, 7], stroke_width=_BORDER_WIDTH, fill=c.border_face, fill_rule='evenodd', # fill_rule "evenodd" is agnostic to polygon vertices orientation # "nonzero" would depend on orientation (if opposite, no fill) d=' '.join( chain( ( 'M' + ' '.join(str(c) for c in VertexS[border].flat) + 'z', ), draw_obstacles, ) ), ) ) elif draw_obstacles: # draw only the obstacles self.borderE.append( svg.Path( id='border', stroke=c.kind2color['border'], stroke_dasharray=[15, 7], stroke_width=_BORDER_WIDTH, fill=c.border_face, d=draw_obstacles, ) ) def _line(self, u, v) -> svg.Line: (x1, y1), (x2, y2) = self.VertexS[self.fnT[u]], self.VertexS[self.fnT[v]] return svg.Line(x1=x1, y1=y1, x2=x2, y2=y2) def _kind_group(self, id: str, kind: str, lines: list, **attrs) -> svg.G: c = self.c if kind in c.kind2dasharray: attrs['stroke_dasharray'] = c.kind2dasharray[kind] return svg.G(id=id, stroke=c.kind2color[kind], elements=lines, **attrs) def add_edges(self): fnT, VertexS = self.fnT, self.VertexS w, h = self.w, self.h edge_widths = [ _LINK_TYPE_WIDTH_STEP * (i + 1) for i, _ in enumerate(self.G.graph.get('cables', (0,))) ] edge_lines_ = [defaultdict(list) for _ in edge_widths] for u, v, edgeD in self.G.edges(data=True): kind = edgeD.get('kind', 'unspecified') if kind == 'detour': # detours are drawn separately as polylines continue u, v = (u, v) if u < v else (v, u) (x1, y1), (x2, y2) = VertexS[fnT[u]], VertexS[fnT[v]] if self.overflow is None and not ( 0 <= x1 <= w and 0 <= y1 <= h and 0 <= x2 <= w and 0 <= y2 <= h ): self.overflow = 'hidden' edge_lines_[edgeD.get('cable', 0)][kind].append( svg.Line(x1=x1, y1=y1, x2=x2, y2=y2) ) edges_super_group = self.edgesE for cable_type, (stroke_width, edge_lines) in enumerate( zip(edge_widths, edge_lines_) ): if len(edge_widths) > 1: # two grouping levels edgesE = [] extra_attrs = {} else: # single grouping level edgesE = edges_super_group extra_attrs = dict(stroke_width=_LINK_WIDTH) for edge_kind, lines in edge_lines.items(): edgesE.append( self._kind_group( 'edges_' + edge_kind, edge_kind, lines, **extra_attrs ) ) if len(edge_widths) > 1: # two grouping levels edges_super_group.append( svg.G( id=f'cable_{cable_type}', stroke_width=stroke_width, elements=edgesE, ) ) # overlay graph (e.g. from PathFinder.best_paths_overlay()) overlay = self.G.graph.get('overlay') if overlay is not None: overlay_by_kind = defaultdict(list) for u, v, edgeD in overlay.edges(data=True): kind = edgeD.get('kind', 'unspecified') u, v = (u, v) if u < v else (v, u) overlay_by_kind[kind].append(self._line(u, v)) kind_groups = [ self._kind_group( f'overlay_{kind}', kind, lines, stroke_width=_LINK_WIDTH, opacity=self.c.kind2alpha[kind], ) for kind, lines in overlay_by_kind.items() ] self.edgesE.append(svg.G(id='overlay', elements=kind_groups)) def add_detours(self, size_selector: int = 0): G, R, T, B = self.G, self.R, self.T, self.B C, D = (G.graph.get(k, 0) for k in 'CD') fnT, c, VertexS = self.fnT, self.c, self.VertexS # reusable ring for indicating clone-vertices self.reusableE.append( svg.Circle( id='dt', r=_RING_RADII[size_selector], fill='none', stroke_opacity=0.3, stroke=c.detour_ring, stroke_width=_DETOUR_RING_WIDTH, ) ) # Detour edges as polylines (to align the dashes among overlapping lines) points__ = defaultdict(list) for r in range(-R, 0): detoured = [n for n in G.neighbors(r) if n >= T + B + C] for t in detoured: s = r hops = [s, fnT[t]] while True: nbr = set(G.neighbors(t)) nbr.remove(s) u = nbr.pop() hops.append(fnT[u]) if u < T: break s, t = t, u points__[G[s][t].get('cable', None)].append( ' '.join(str(c) for c in VertexS[hops].flat) ) common_attr = dict( stroke=c.kind2color['detour'], stroke_dasharray=[18, 15], fill='none', ) if None in points__: detours = [ svg.G( id='detours', **common_attr, stroke_width=_LINK_WIDTH, elements=[svg.Polyline(points=points) for points in points__[None]], ), ] else: detours = [ svg.G( id=f'detours_{cable_type}', **common_attr, stroke_width=_LINK_TYPE_WIDTH_STEP * (cable_type + 1), elements=[svg.Polyline(points=points) for points in points_], ) for cable_type, points_ in points__.items() ] self.detoursE.extend( ( *detours, svg.G( # Detour nodes id='DTgrp', elements=[ svg.Use(href='#dt', x=VertexS[d, 0], y=VertexS[d, 1]) for d in fnT[T + B + C : T + B + C + D] ], ), ) ) def add_nodes(self, node_tag: str | bool | None = None): node_radius = _NODE_RADII[node_tag is not None] c, VertexS = self.c, self.VertexS G, R, T = self.G, self.R, self.T # reusable elements self.root_side = root_side = round(1.77 * node_radius) self.reusableE.extend( ( svg.Circle( id='wtg', stroke=c.term_edge, stroke_width=_NODE_EDGE_WIDTH, r=node_radius, ), svg.Rect( id='oss', fill=c.root_face, stroke=c.root_edge, stroke_width=_NODE_EDGE_WIDTH, width=root_side, height=root_side, ), ) ) # nodes subtrees = defaultdict(list) for n, sub in G.nodes(data='subtree', default=19): if 0 <= n < T: subtrees[sub].append(n) terminals = [] for sub, nodes in subtrees.items(): terminals.append( svg.G( fill=c.colors[sub % len(c.colors)], elements=[ svg.Use(href='#wtg', x=VertexS[n, 0], y=VertexS[n, 1]) for n in nodes ], ) ) self.nodesE.extend( ( svg.G(id='WTGgrp', elements=terminals), svg.G( id='OSSgrp', elements=[ svg.Use( href='#oss', x=VertexS[r, 0] - root_side / 2, y=VertexS[r, 1] - root_side / 2, ) for r in range(-R, 0) ], ), ) ) # node labels if node_tag is not None: has_loads = G.graph.get('has_loads', False) def get_label(n): if node_tag is True: return str(n) if node_tag == 'load' and has_loads: return str(G.nodes[n].get('load', '-')) if isinstance(node_tag, str): val = G.nodes[n].get(node_tag, '') return str(val) if val != '' else '' return '' base_attrs = { 'font-family': 'sans-serif', 'text-anchor': 'middle', 'dominant-baseline': 'central', } # turbine/root font sizes mirror gplot's per-tag scheme, whose # FONTSIZE_ROOT_LABEL : FONTSIZE_LABEL : FONTSIZE_LOAD = 4 : 5 : 7 small, normal, large = (round(node_radius * f) for f in (0.8, 1.0, 1.4)) if node_tag == 'load' and has_loads: wtg_font, oss_font = large, normal elif node_tag is True: wtg_font, oss_font = normal, large else: wtg_font, oss_font = normal, small wtg_labels = [ svg.Text(x=VertexS[n, 0], y=VertexS[n, 1], text=lbl) for n in range(T) if (lbl := get_label(n)) ] oss_labels = [ svg.Text(x=VertexS[r, 0], y=VertexS[r, 1], text=lbl) for r in range(-R, 0) if (lbl := get_label(r)) ] if wtg_labels: self.nodesE.append( svg.G( id='WTGlabels', fill='black', extra={'font-size': wtg_font, **base_attrs}, elements=wtg_labels, ) ) if oss_labels: self.nodesE.append( svg.G( id='OSSlabels', fill=c.root_edge, extra={'font-size': oss_font, **base_attrs}, elements=oss_labels, ) ) def add_border_tags(self, node_radius: int = 12): G, c, VertexS = self.G, self.c, self.VertexS border = G.graph.get('border') obstacles = G.graph.get('obstacles') border_ = border if border is not None else [] obstacles_ = obstacles if obstacles is not None else [()] tags = [ svg.Text(x=VertexS[b, 0], y=VertexS[b, 1], text=str(b)) for b in chain(border_, *obstacles_) ] if tags: self.nodesE.append( svg.G( id='border_tags', fill=c.fg_color, extra={ 'font-size': round(node_radius * 1.3), 'font-family': 'sans-serif', }, elements=tags, ) ) def add_box(self, github_bugfix: bool = True): self.reusableE.append( svg.Filter( id='bg_textbox', x=svg.Length(-5, '%'), y=svg.Length(-5, '%'), width=svg.Length(110, '%'), height=svg.Length(110, '%'), elements=[ svg.FeFlood( flood_color=self.c.bg_color, flood_opacity=0.6, result='bg' ), svg.FeMerge( elements=[ svg.FeMergeNode(in_='bg'), svg.FeMergeNode(in_='SourceGraphic'), ] ), ], ) ) desc_lines = describe_G(self.G)[::-1] if github_bugfix: # this is a workaround for GitHub's bug in rendering svg utf8 text # (only when the svg is inside an ipynb notebook) desc_lines = [ line.encode('ascii', 'xmlcharrefreplace').decode() for line in desc_lines ] linesE: list[svg.Element] = [ svg.TSpan( x=self.bottom_right_anchor['x'], # dx=svg.Length(-0.2, 'em'), dy=svg.Length((-1.3 if i else -0.0), 'em'), text=line, ) for i, line in enumerate(desc_lines) ] self.infoboxE.append( svg.Text( **self.bottom_right_anchor, elements=linesE, fill=self.c.fg_color, font_size=40, text_anchor='end', font_family='sans-serif', filter='url(#bg_textbox)', ) ) def add_legend(self): c, G = self.c, self.G legend_items = [] # 1. WTG legend_items.append(('node', 'wtg', 'WTG', c.colors[0], 'circle')) # 2. OSS legend_items.append(('node', 'oss', 'OSS', c.root_face, 'rect')) # 3. corner (if detour/clone exists) if G.graph.get('D', 0) > 0: legend_items.append(('node', 'corner', 'corner', 'none', 'ring')) # 4. Edges (collect unique kinds from G and overlay if any) kinds = set() for u, v, k in G.edges.data('kind'): if k is not None: kinds.add(k) else: kinds.add('route') overlay = G.graph.get('overlay') if overlay is not None: for u, v, k in overlay.edges.data('kind'): if k is not None: kinds.add(k) else: kinds.add('route') for kind in sorted(kinds): color_key = None if kind == 'route' else kind legend_items.append( ( 'edge', kind, kind, c.kind2color.get(color_key, c.fg_color), c.kind2dasharray.get(color_key), ) ) # Layout metrics item_width = 180 N = len(legend_items) total_width = N * item_width bbox_center_x = (self.bottom_right_anchor['x'] + self.margin) / 2 start_x = bbox_center_x - total_width / 2 y_pos = self.h_orig + 40 elements = [] labels = [] for i, item in enumerate(legend_items): x_pos = start_x + i * item_width item_type = item[0] label = '' if item_type == 'node': _, name, label, color, shape = item if shape == 'circle': elements.append( svg.Use(href='#wtg', x=x_pos + 20, y=y_pos, fill=color) ) elif shape == 'rect': elements.append( svg.Use( href='#oss', x=x_pos + 20 - self.root_side / 2, y=y_pos - self.root_side / 2, ) ) elif shape == 'ring': elements.append(svg.Use(href='#dt', x=x_pos + 20, y=y_pos)) elif item_type == 'edge': _, name, label, color, dash = item attrs = { 'x1': x_pos, 'y1': y_pos, 'x2': x_pos + 40, 'y2': y_pos, 'stroke': color, 'stroke_width': _LINK_WIDTH, } if dash: attrs['stroke_dasharray'] = dash elements.append(svg.Line(**attrs)) labels.append(svg.Text(x=x_pos + 50, y=y_pos, text=label)) elements.append( svg.G( id='legend_labels', fill=c.fg_color, extra={ 'font-size': '24', 'font-family': 'sans-serif', 'dominant-baseline': 'central', }, elements=labels, ) ) self.toplevelE.append(svg.G(id='legend', elements=elements)) def to_svg(self) -> str: if self.legend: self.add_legend() # elements should be added according to the desired z-order graphElements = [*self.borderE, *self.edgesE, *self.detoursE, *self.nodesE] self.toplevelE.extend( ( svg.Defs(elements=self.reusableE), svg.G(id=self.handle, elements=graphElements), *self.infoboxE, ) ) # Aggregate all elements in the SVG figure. out = svg.SVG( viewBox=self.viewBox, overflow=self.overflow, elements=self.toplevelE, ) return out.as_str()
[docs] def svgplot( G: nx.Graph, *, landscape: bool = True, node_tag: str | bool | None = None, tag_border: bool = False, infobox: bool = True, legend: bool = False, dark: bool | None = None, transparent: bool = True, github_bugfix: bool = True, ) -> SvgRepr: """Draw a NetworkX graph representation as SVG markup. If using interactively (e.g. Jupyter notebook), the returned object must either be the cell's output or be passed to IPython's display() function. Alternative to own.plotting.gplot() because matplotlib's svg backend does not make efficient use of SVG primitives. Args: G: graph to plot landscape: rotate(?) the plot by G's graph attribute 'landscape_angle'. node_tag: text label inside each node. Use True for node numbers, 'load' for power flow values (requires has_loads), or any node attribute name. tag_border: if True, label all border and obstacle vertices with their index numbers (useful for geometry debugging). infobox: add(?) text box with summary of G's main properties: capacity, number of turbines, excess feeders, total feeders, total cable length. legend: if True, add a legend strip at the bottom of the SVG plot. dark: color theme to use: True -> dark; False: light; None -> guess transparent: background color: True -> transparent; False -> theme-based Returns: SvgRepr object containing the SVG markup in its 'data' attribute """ drawable = Drawable( G, landscape=landscape, dark=dark, transparent=transparent, legend=legend, ) drawable.add_edges() if G.graph.get('D', False): drawable.add_detours(size_selector=int(node_tag is not None)) drawable.add_nodes(node_tag=node_tag) if tag_border: drawable.add_border_tags() if infobox and G.graph.get('capacity') is not None: drawable.add_box(github_bugfix=github_bugfix) return SvgRepr(drawable.to_svg(), drawable.metadata)
[docs] def svgpplot(P: nx.PlanarEmbedding, A: nx.Graph, **kwargs) -> SvgRepr: """Plot PlanarEmbedding `P` using coordinates from `A` as SVG markup. SVG equivalent of `plotting.pplot()`. Accepts the same keyword arguments as `svgplot()`. Args: P: planar embedding to plot. A: source of vertex coordinates and node attributes. Returns: SvgRepr object containing the SVG markup in its 'data' attribute """ H = nx.create_empty_copy(A) if 'has_loads' in H.graph: del H.graph['has_loads'] R, T, B = (A.graph[k] for k in 'RTB') H.add_edges_from(P.edges, kind='planar') fnT = np.arange(R + T + B + 3) fnT[-R:] = range(-R, 0) H.graph['fnT'] = fnT return svgplot(H, **kwargs)