# SPDX-License-Identifier: MIT
# https://gitlab.windenergy.dtu.dk/TOPFARM/OptiWindNet/
from collections import defaultdict
from itertools import chain
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')
[docs]
class SvgRepr:
"""
Helper class to get IPython to display the SVG figure encoded in data.
"""
def __init__(self, data: str):
def _repr_svg_(self) -> str:
return self.data
[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.
"""
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]
def __init__(
self,
G: nx.Graph,
*,
landscape: bool = True,
dark: bool | None = None,
transparent: bool = True,
):
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
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 = 30
# 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.viewBox = svg.ViewBoxSpec(0, 0, w, h)
#######################
# 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=2,
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=2,
fill=c.border_face,
d=draw_obstacles,
)
)
def add_edges(self):
fnT, c, VertexS = self.fnT, self.c, self.VertexS
edge_widths = [
(3 + 3 * i) 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)
edge_lines_[edgeD.get('cable', 0)][kind].append(
svg.Line(
x1=VertexS[fnT[u], 0],
y1=VertexS[fnT[u], 1],
x2=VertexS[fnT[v], 0],
y2=VertexS[fnT[v], 1],
)
)
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=4)
for edge_kind, lines in edge_lines.items():
group_attrs = {}
if edge_kind in c.kind2dasharray:
group_attrs['stroke_dasharray'] = c.kind2dasharray[edge_kind]
edgesE.append(
svg.G(
id='edges_' + edge_kind,
stroke=c.kind2color[edge_kind],
**extra_attrs,
**group_attrs,
elements=lines,
)
)
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,
)
)
def add_detours(self):
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=23,
fill='none',
stroke_opacity=0.3,
stroke=c.detour_ring,
stroke_width=4,
)
)
# 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=4,
elements=[svg.Polyline(points=points) for points in points__[None]],
),
]
else:
detours = [
svg.G(
id=f'detours_{cable_type}',
**common_attr,
stroke_width=3 + 3 * cable_type,
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_size: int = 12):
c, VertexS = self.c, self.VertexS
G, R, T = self.G, self.R, self.T
# reusable elements
root_side = round(1.77 * node_size)
self.reusableE.extend(
(
svg.Circle(id='wtg', stroke=c.term_edge, stroke_width=2, r=node_size),
svg.Rect(
id='oss',
fill=c.root_face,
stroke=c.root_edge,
stroke_width=2,
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)
],
),
)
)
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 to_svg(self) -> str:
# 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.G.graph.get(
'handle', self.G.graph.get('name', 'handleless')
),
elements=graphElements,
),
*self.infoboxE,
)
)
# Aggregate all elements in the SVG figure.
out = svg.SVG(
viewBox=self.viewBox,
elements=self.toplevelE,
)
return out.as_str()
[docs]
def svgplot(
G: nx.Graph,
*,
landscape: bool = True,
infobox: bool = True,
dark: bool | None = None,
transparent: bool = True,
node_size: int = 12,
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'.
infobox: add(?) text box with summary of G's main properties: capacity,
number of turbines, excess feeders, total feeders, total cable length.
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)
drawable.add_edges()
if G.graph.get('D', False):
drawable.add_detours()
drawable.add_nodes(node_size=node_size)
if infobox and G.graph.get('has_loads', False):
drawable.add_box(github_bugfix=github_bugfix)
return SvgRepr(drawable.to_svg())