# 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
edges_with_kind = self.G.edges(data='kind')
edge_lines = defaultdict(list)
for u, v, edge_kind in edges_with_kind:
if edge_kind == 'detour':
# detours are drawn separately as polylines
continue
if edge_kind is None:
edge_kind = 'unspecified'
u, v = (u, v) if u < v else (v, u)
edge_lines[edge_kind].append(
svg.Line(
x1=VertexS[fnT[u], 0],
y1=VertexS[fnT[u], 1],
x2=VertexS[fnT[v], 0],
y2=VertexS[fnT[v], 1],
)
)
edgesE = self.edgesE
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],
stroke_width=4,
**group_attrs,
elements=lines,
)
)
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 = []
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.append(' '.join(str(c) for c in VertexS[hops].flat))
self.detoursE.extend(
(
svg.G(
id='detours',
stroke=c.kind2color['detour'],
stroke_width=4,
stroke_dasharray=[18, 15],
fill='none',
elements=[svg.Polyline(points=points) for points in Points],
),
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())