unifi-network-maps 1.2.1__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_network_maps/__init__.py +1 -0
- unifi_network_maps/adapters/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
- {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
- unifi_network_maps/assets/themes/dark.yaml +47 -0
- unifi_network_maps/assets/themes/default.yaml +47 -0
- unifi_network_maps/cli/__init__.py +41 -0
- unifi_network_maps/cli/__main__.py +8 -0
- unifi_network_maps/cli/main.py +281 -0
- unifi_network_maps/io/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
- unifi_network_maps/model/__init__.py +1 -0
- unifi_network_maps/model/labels.py +35 -0
- {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
- unifi_network_maps/model/ports.py +23 -0
- {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
- unifi_network_maps/render/__init__.py +1 -0
- {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
- unifi_network_maps/render/mermaid_theme.py +46 -0
- {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
- unifi_network_maps/render/svg_theme.py +64 -0
- unifi_network_maps/render/theme.py +90 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
- unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
- unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
- unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
- unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
- unifi_mermaid/__init__.py +0 -1
- unifi_mermaid/cli.py +0 -197
- unifi_mermaid/labels.py +0 -15
- unifi_network_maps-1.2.1.dist-info/RECORD +0 -63
- unifi_network_maps-1.2.1.dist-info/entry_points.txt +0 -2
- unifi_network_maps-1.2.1.dist-info/licenses/LICENSES.md +0 -10
- unifi_network_maps-1.2.1.dist-info/top_level.txt +0 -1
- {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
- {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
- {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,7 +7,8 @@ import math
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from .topology import Edge
|
|
10
|
+
from ..model.topology import Edge
|
|
11
|
+
from .svg_theme import DEFAULT_THEME, SvgTheme, svg_defs
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass(frozen=True)
|
|
@@ -23,6 +24,45 @@ class SvgOptions:
|
|
|
23
24
|
height: int | None = None
|
|
24
25
|
|
|
25
26
|
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class IsoLayout:
|
|
29
|
+
iso_angle: float
|
|
30
|
+
tile_width: float
|
|
31
|
+
tile_height: float
|
|
32
|
+
step_width: float
|
|
33
|
+
step_height: float
|
|
34
|
+
grid_spacing_x: int
|
|
35
|
+
grid_spacing_y: int
|
|
36
|
+
padding: float
|
|
37
|
+
tile_y_offset: float
|
|
38
|
+
extra_pad: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _iso_layout(options: SvgOptions) -> IsoLayout:
|
|
42
|
+
tile_width = options.node_width * 1.5
|
|
43
|
+
iso_angle = math.radians(30.0)
|
|
44
|
+
tile_height = tile_width * math.tan(iso_angle)
|
|
45
|
+
step_width = tile_width
|
|
46
|
+
step_height = tile_height
|
|
47
|
+
grid_spacing_x = max(2, 1 + int(round(options.h_gap / max(tile_width, 1))))
|
|
48
|
+
grid_spacing_y = max(2, 1 + int(round(options.v_gap / max(tile_height, 1))))
|
|
49
|
+
padding = float(options.padding)
|
|
50
|
+
tile_y_offset = tile_height / 2
|
|
51
|
+
extra_pad = max(12.0, tile_width * 0.35)
|
|
52
|
+
return IsoLayout(
|
|
53
|
+
iso_angle=iso_angle,
|
|
54
|
+
tile_width=tile_width,
|
|
55
|
+
tile_height=tile_height,
|
|
56
|
+
step_width=step_width,
|
|
57
|
+
step_height=step_height,
|
|
58
|
+
grid_spacing_x=grid_spacing_x,
|
|
59
|
+
grid_spacing_y=grid_spacing_y,
|
|
60
|
+
padding=padding,
|
|
61
|
+
tile_y_offset=tile_y_offset,
|
|
62
|
+
extra_pad=extra_pad,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
26
66
|
_TYPE_ORDER = ["gateway", "switch", "ap", "client", "other"]
|
|
27
67
|
_ICON_FILES = {
|
|
28
68
|
"gateway": "router-network.svg",
|
|
@@ -93,6 +133,136 @@ def _compact_edge_label(
|
|
|
93
133
|
return label
|
|
94
134
|
|
|
95
135
|
|
|
136
|
+
def _iso_tile_points(
|
|
137
|
+
center_x: float, center_y: float, width: float, height: float
|
|
138
|
+
) -> list[tuple[float, float]]:
|
|
139
|
+
return [
|
|
140
|
+
(center_x, center_y - height / 2),
|
|
141
|
+
(center_x + width / 2, center_y),
|
|
142
|
+
(center_x, center_y + height / 2),
|
|
143
|
+
(center_x - width / 2, center_y),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _points_to_svg(points: list[tuple[float, float]]) -> str:
|
|
148
|
+
return " ".join(f"{px},{py}" for px, py in points)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_port_label_lines(
|
|
152
|
+
port_label: str,
|
|
153
|
+
*,
|
|
154
|
+
node_type: str,
|
|
155
|
+
prefix: str,
|
|
156
|
+
max_chars: int,
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
def _port_only(segment: str) -> str:
|
|
159
|
+
port = _extract_port_text(segment)
|
|
160
|
+
if port:
|
|
161
|
+
return port
|
|
162
|
+
lower = segment.lower()
|
|
163
|
+
idx = lower.rfind("port ")
|
|
164
|
+
if idx != -1:
|
|
165
|
+
return segment[idx:].strip()
|
|
166
|
+
return segment.split(":", 1)[-1].strip()
|
|
167
|
+
|
|
168
|
+
def _truncate(text: str, max_len: int = max_chars) -> str:
|
|
169
|
+
return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
|
|
170
|
+
|
|
171
|
+
if "<->" in port_label:
|
|
172
|
+
left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
|
|
173
|
+
front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
|
|
174
|
+
side_prefix = prefix if node_type == "client" else "local"
|
|
175
|
+
side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
|
|
176
|
+
return [line for line in (front_text, side_text) if line]
|
|
177
|
+
side_prefix = prefix if node_type == "client" else "local"
|
|
178
|
+
side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
|
|
179
|
+
return [side_text]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _iso_front_text_position(
|
|
183
|
+
top_points: list[tuple[float, float]], tile_width: float, tile_height: float
|
|
184
|
+
) -> tuple[float, float, float]:
|
|
185
|
+
left_edge_top = top_points[0]
|
|
186
|
+
left_edge_bottom = top_points[3]
|
|
187
|
+
edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
|
|
188
|
+
edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
|
|
189
|
+
center_x = sum(px for px, _py in top_points) / len(top_points)
|
|
190
|
+
center_y = sum(py for _px, py in top_points) / len(top_points)
|
|
191
|
+
normal_x = center_x - edge_mid_x
|
|
192
|
+
normal_y = center_y - edge_mid_y
|
|
193
|
+
normal_len = math.hypot(normal_x, normal_y) or 1.0
|
|
194
|
+
normal_x /= normal_len
|
|
195
|
+
normal_y /= normal_len
|
|
196
|
+
inset = tile_height * 0.27
|
|
197
|
+
text_x = edge_mid_x + normal_x * inset - tile_width * 0.16
|
|
198
|
+
text_y = edge_mid_y + normal_y * inset + tile_height * 0.02
|
|
199
|
+
name_edge_left = top_points[3]
|
|
200
|
+
name_edge_right = top_points[2]
|
|
201
|
+
angle = math.degrees(
|
|
202
|
+
math.atan2(
|
|
203
|
+
name_edge_right[1] - name_edge_left[1],
|
|
204
|
+
name_edge_right[0] - name_edge_left[0],
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return text_x, text_y, angle
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _render_iso_text(
|
|
211
|
+
lines: list[str],
|
|
212
|
+
*,
|
|
213
|
+
text_x: float,
|
|
214
|
+
text_y: float,
|
|
215
|
+
angle: float,
|
|
216
|
+
text_lines: list[str],
|
|
217
|
+
font_size: int,
|
|
218
|
+
fill: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
line_height = font_size + 2
|
|
221
|
+
start_y = text_y - (len(text_lines) - 1) * line_height / 2
|
|
222
|
+
text_transform = (
|
|
223
|
+
f"translate({text_x} {start_y}) rotate({angle}) skewX(30) translate({-text_x} {-start_y})"
|
|
224
|
+
)
|
|
225
|
+
lines.append(
|
|
226
|
+
f'<text x="{text_x}" y="{start_y}" text-anchor="middle" fill="{fill}" '
|
|
227
|
+
f'font-size="{font_size}" font-style="normal" '
|
|
228
|
+
f'transform="{text_transform}">'
|
|
229
|
+
)
|
|
230
|
+
for idx, line in enumerate(text_lines):
|
|
231
|
+
dy = 0 if idx == 0 else line_height
|
|
232
|
+
lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
|
|
233
|
+
lines.append("</text>")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _iso_name_label_position(
|
|
237
|
+
top_points: list[tuple[float, float]],
|
|
238
|
+
*,
|
|
239
|
+
tile_width: float,
|
|
240
|
+
tile_height: float,
|
|
241
|
+
font_size: int,
|
|
242
|
+
) -> tuple[float, float, float]:
|
|
243
|
+
name_edge_left = top_points[3]
|
|
244
|
+
name_edge_right = top_points[2]
|
|
245
|
+
name_mid_x = (name_edge_left[0] + name_edge_right[0]) / 2
|
|
246
|
+
name_mid_y = (name_edge_left[1] + name_edge_right[1]) / 2
|
|
247
|
+
name_center_x = sum(px for px, _py in top_points) / len(top_points)
|
|
248
|
+
name_center_y = sum(py for _px, py in top_points) / len(top_points)
|
|
249
|
+
name_normal_x = name_center_x - name_mid_x
|
|
250
|
+
name_normal_y = name_center_y - name_mid_y
|
|
251
|
+
name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
|
|
252
|
+
name_normal_x /= name_normal_len
|
|
253
|
+
name_normal_y /= name_normal_len
|
|
254
|
+
name_inset = tile_height * 0.13
|
|
255
|
+
name_x = name_mid_x + name_normal_x * name_inset - tile_width * 0.08
|
|
256
|
+
name_y = name_mid_y + name_normal_y * name_inset + font_size - tile_height * 0.05
|
|
257
|
+
name_angle = math.degrees(
|
|
258
|
+
math.atan2(
|
|
259
|
+
name_edge_right[1] - name_edge_left[1],
|
|
260
|
+
name_edge_right[0] - name_edge_left[0],
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return name_x, name_y, name_angle
|
|
264
|
+
|
|
265
|
+
|
|
96
266
|
def _wrap_text(label: str, *, max_len: int = 24) -> list[str]:
|
|
97
267
|
if len(label) <= max_len:
|
|
98
268
|
return [label]
|
|
@@ -250,6 +420,7 @@ def render_svg(
|
|
|
250
420
|
*,
|
|
251
421
|
node_types: dict[str, str],
|
|
252
422
|
options: SvgOptions | None = None,
|
|
423
|
+
theme: SvgTheme = DEFAULT_THEME,
|
|
253
424
|
) -> str:
|
|
254
425
|
options = options or SvgOptions()
|
|
255
426
|
icons = _load_icons()
|
|
@@ -260,42 +431,12 @@ def render_svg(
|
|
|
260
431
|
lines = [
|
|
261
432
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
262
433
|
f'viewBox="0 0 {width} {height}">',
|
|
263
|
-
"
|
|
264
|
-
'<linearGradient id="link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
265
|
-
'<stop offset="0%" stop-color="#16a085"/>'
|
|
266
|
-
'<stop offset="100%" stop-color="#2ecc71"/>'
|
|
267
|
-
"</linearGradient>"
|
|
268
|
-
'<linearGradient id="link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
269
|
-
'<stop offset="0%" stop-color="#1e88e5"/>'
|
|
270
|
-
'<stop offset="100%" stop-color="#42a5f5"/>'
|
|
271
|
-
"</linearGradient>"
|
|
272
|
-
'<linearGradient id="node-gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
273
|
-
'<stop offset="0%" stop-color="#ffd199"/>'
|
|
274
|
-
'<stop offset="100%" stop-color="#ffb15a"/>'
|
|
275
|
-
"</linearGradient>"
|
|
276
|
-
'<linearGradient id="node-switch" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
277
|
-
'<stop offset="0%" stop-color="#bfe4ff"/>'
|
|
278
|
-
'<stop offset="100%" stop-color="#8ac6ff"/>'
|
|
279
|
-
"</linearGradient>"
|
|
280
|
-
'<linearGradient id="node-ap" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
281
|
-
'<stop offset="0%" stop-color="#c4f2d4"/>'
|
|
282
|
-
'<stop offset="100%" stop-color="#8ee3b4"/>'
|
|
283
|
-
"</linearGradient>"
|
|
284
|
-
'<linearGradient id="node-client" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
285
|
-
'<stop offset="0%" stop-color="#e4ccff"/>'
|
|
286
|
-
'<stop offset="100%" stop-color="#c5a4ff"/>'
|
|
287
|
-
"</linearGradient>"
|
|
288
|
-
'<linearGradient id="node-other" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
289
|
-
'<stop offset="0%" stop-color="#e3e3e3"/>'
|
|
290
|
-
'<stop offset="100%" stop-color="#cfcfcf"/>'
|
|
291
|
-
"</linearGradient>"
|
|
292
|
-
"</defs>",
|
|
434
|
+
svg_defs("", theme),
|
|
293
435
|
f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
|
|
294
436
|
]
|
|
295
437
|
|
|
296
438
|
node_port_labels: dict[str, str] = {}
|
|
297
439
|
node_port_prefix: dict[str, str] = {}
|
|
298
|
-
node_port_prefix: dict[str, str] = {}
|
|
299
440
|
for edge in edges:
|
|
300
441
|
if edge.left not in positions or edge.right not in positions:
|
|
301
442
|
continue
|
|
@@ -396,19 +537,20 @@ def render_svg_isometric(
|
|
|
396
537
|
*,
|
|
397
538
|
node_types: dict[str, str],
|
|
398
539
|
options: SvgOptions | None = None,
|
|
540
|
+
theme: SvgTheme = DEFAULT_THEME,
|
|
399
541
|
) -> str:
|
|
400
542
|
options = options or SvgOptions()
|
|
401
543
|
icons = _load_isometric_icons()
|
|
402
544
|
positions_index, levels = _tree_layout_indices(edges, node_types)
|
|
403
545
|
if not positions_index:
|
|
404
546
|
positions_index = {}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
tile_h =
|
|
408
|
-
step_w =
|
|
409
|
-
step_h =
|
|
410
|
-
grid_spacing_x =
|
|
411
|
-
grid_spacing_y =
|
|
547
|
+
layout = _iso_layout(options)
|
|
548
|
+
tile_w = layout.tile_width
|
|
549
|
+
tile_h = layout.tile_height
|
|
550
|
+
step_w = layout.step_width
|
|
551
|
+
step_h = layout.step_height
|
|
552
|
+
grid_spacing_x = layout.grid_spacing_x
|
|
553
|
+
grid_spacing_y = layout.grid_spacing_y
|
|
412
554
|
|
|
413
555
|
grid_positions: dict[str, tuple[float, float]] = {}
|
|
414
556
|
positions: dict[str, tuple[float, float]] = {}
|
|
@@ -438,8 +580,8 @@ def render_svg_isometric(
|
|
|
438
580
|
min_x = min_y = 0.0
|
|
439
581
|
max_x = max_y = 0.0
|
|
440
582
|
|
|
441
|
-
padding =
|
|
442
|
-
tile_y_offset =
|
|
583
|
+
padding = layout.padding
|
|
584
|
+
tile_y_offset = layout.tile_y_offset
|
|
443
585
|
offset_x = -min_x + padding
|
|
444
586
|
offset_y = -min_y + padding + tile_y_offset
|
|
445
587
|
for name, (x, y) in positions.items():
|
|
@@ -457,8 +599,8 @@ def render_svg_isometric(
|
|
|
457
599
|
cx, cy = grid_center(gx, gy)
|
|
458
600
|
return cx, cy
|
|
459
601
|
|
|
460
|
-
width = max_x - min_x + tile_w + padding * 2
|
|
461
|
-
height = max_y - min_y + tile_h + padding * 2 + tile_y_offset
|
|
602
|
+
width = max_x - min_x + tile_w + padding * 2 + layout.extra_pad
|
|
603
|
+
height = max_y - min_y + tile_h + padding * 2 + tile_y_offset + layout.extra_pad
|
|
462
604
|
|
|
463
605
|
out_width = options.width or int(width)
|
|
464
606
|
out_height = options.height or int(height)
|
|
@@ -466,36 +608,7 @@ def render_svg_isometric(
|
|
|
466
608
|
lines = [
|
|
467
609
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
468
610
|
f'viewBox="0 0 {width} {height}">',
|
|
469
|
-
"
|
|
470
|
-
'<linearGradient id="iso-link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
471
|
-
'<stop offset="0%" stop-color="#16a085"/>'
|
|
472
|
-
'<stop offset="100%" stop-color="#2ecc71"/>'
|
|
473
|
-
"</linearGradient>"
|
|
474
|
-
'<linearGradient id="iso-link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
475
|
-
'<stop offset="0%" stop-color="#1e88e5"/>'
|
|
476
|
-
'<stop offset="100%" stop-color="#42a5f5"/>'
|
|
477
|
-
"</linearGradient>"
|
|
478
|
-
'<linearGradient id="iso-node-gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
479
|
-
'<stop offset="0%" stop-color="#ffd199"/>'
|
|
480
|
-
'<stop offset="100%" stop-color="#ffb15a"/>'
|
|
481
|
-
"</linearGradient>"
|
|
482
|
-
'<linearGradient id="iso-node-switch" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
483
|
-
'<stop offset="0%" stop-color="#bfe4ff"/>'
|
|
484
|
-
'<stop offset="100%" stop-color="#8ac6ff"/>'
|
|
485
|
-
"</linearGradient>"
|
|
486
|
-
'<linearGradient id="iso-node-ap" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
487
|
-
'<stop offset="0%" stop-color="#c4f2d4"/>'
|
|
488
|
-
'<stop offset="100%" stop-color="#8ee3b4"/>'
|
|
489
|
-
"</linearGradient>"
|
|
490
|
-
'<linearGradient id="iso-node-client" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
491
|
-
'<stop offset="0%" stop-color="#e4ccff"/>'
|
|
492
|
-
'<stop offset="100%" stop-color="#c5a4ff"/>'
|
|
493
|
-
"</linearGradient>"
|
|
494
|
-
'<linearGradient id="iso-node-other" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
495
|
-
'<stop offset="0%" stop-color="#e3e3e3"/>'
|
|
496
|
-
'<stop offset="100%" stop-color="#cfcfcf"/>'
|
|
497
|
-
"</linearGradient>"
|
|
498
|
-
"</defs>",
|
|
611
|
+
svg_defs("iso", theme),
|
|
499
612
|
f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
|
|
500
613
|
]
|
|
501
614
|
|
|
@@ -537,15 +650,6 @@ def render_svg_isometric(
|
|
|
537
650
|
node_port_labels: dict[str, str] = {}
|
|
538
651
|
node_port_prefix: dict[str, str] = {}
|
|
539
652
|
|
|
540
|
-
def iso_tile_points(cx: float, cy: float, width: float, height: float) -> str:
|
|
541
|
-
points = [
|
|
542
|
-
(cx, cy - height / 2),
|
|
543
|
-
(cx + width / 2, cy),
|
|
544
|
-
(cx, cy + height / 2),
|
|
545
|
-
(cx - width / 2, cy),
|
|
546
|
-
]
|
|
547
|
-
return " ".join(f"{px},{py}" for px, py in points)
|
|
548
|
-
|
|
549
653
|
for edge in edges:
|
|
550
654
|
if edge.left not in positions or edge.right not in positions:
|
|
551
655
|
continue
|
|
@@ -667,14 +771,9 @@ def render_svg_isometric(
|
|
|
667
771
|
label_center_x = center_x
|
|
668
772
|
stack_depth = tile_h / 2
|
|
669
773
|
label_center_y = y + tile_height / 2 - stack_depth
|
|
670
|
-
|
|
774
|
+
top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
|
|
775
|
+
tile_points = _points_to_svg(top_points)
|
|
671
776
|
# Stack a shallow side to suggest elevation.
|
|
672
|
-
top_points = [
|
|
673
|
-
(label_center_x, label_center_y - tile_height / 2),
|
|
674
|
-
(label_center_x + tile_width / 2, label_center_y),
|
|
675
|
-
(label_center_x, label_center_y + tile_height / 2),
|
|
676
|
-
(label_center_x - tile_width / 2, label_center_y),
|
|
677
|
-
]
|
|
678
777
|
bottom_points = [(px, py + stack_depth) for px, py in top_points]
|
|
679
778
|
# Right face uses points 1->2 and their offset counterparts.
|
|
680
779
|
right_face = [
|
|
@@ -707,18 +806,6 @@ def render_svg_isometric(
|
|
|
707
806
|
icon_center_x = label_center_x
|
|
708
807
|
icon_center_y = label_center_y
|
|
709
808
|
if port_label:
|
|
710
|
-
|
|
711
|
-
def _port_only(segment: str) -> str:
|
|
712
|
-
port = _extract_port_text(segment)
|
|
713
|
-
if port:
|
|
714
|
-
return port
|
|
715
|
-
lower = segment.lower()
|
|
716
|
-
idx = lower.rfind("port ")
|
|
717
|
-
if idx != -1:
|
|
718
|
-
return segment[idx:].strip()
|
|
719
|
-
return segment.split(":", 1)[-1].strip()
|
|
720
|
-
|
|
721
|
-
# Place port text along the front edge of the top tile.
|
|
722
809
|
left_edge_top = top[0]
|
|
723
810
|
left_edge_bottom = top[3]
|
|
724
811
|
edge_len = math.hypot(
|
|
@@ -726,66 +813,26 @@ def render_svg_isometric(
|
|
|
726
813
|
left_edge_bottom[1] - left_edge_top[1],
|
|
727
814
|
)
|
|
728
815
|
max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
|
|
729
|
-
|
|
730
|
-
def _truncate(text: str, max_len: int = max_chars) -> str:
|
|
731
|
-
return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
|
|
732
|
-
|
|
733
816
|
prefix = node_port_prefix.get(name, "switch")
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
else:
|
|
740
|
-
front_text = ""
|
|
741
|
-
side_prefix = prefix if node_type == "client" else "local"
|
|
742
|
-
side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
|
|
743
|
-
|
|
744
|
-
edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
|
|
745
|
-
edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
|
|
746
|
-
center_x = sum(px for px, _py in top) / len(top)
|
|
747
|
-
center_y = sum(py for _px, py in top) / len(top)
|
|
748
|
-
normal_x = center_x - edge_mid_x
|
|
749
|
-
normal_y = center_y - edge_mid_y
|
|
750
|
-
normal_len = math.hypot(normal_x, normal_y) or 1.0
|
|
751
|
-
normal_x /= normal_len
|
|
752
|
-
normal_y /= normal_len
|
|
753
|
-
inset = tile_h * 0.27
|
|
754
|
-
text_x = edge_mid_x + normal_x * inset - tile_w * 0.16
|
|
755
|
-
text_y = edge_mid_y + normal_y * inset + tile_h * 0.02
|
|
756
|
-
edge_angle = math.degrees(
|
|
757
|
-
math.atan2(
|
|
758
|
-
left_edge_bottom[1] - left_edge_top[1],
|
|
759
|
-
left_edge_bottom[0] - left_edge_top[0],
|
|
760
|
-
)
|
|
817
|
+
front_lines = _format_port_label_lines(
|
|
818
|
+
port_label,
|
|
819
|
+
node_type=node_type,
|
|
820
|
+
prefix=prefix,
|
|
821
|
+
max_chars=max_chars,
|
|
761
822
|
)
|
|
762
|
-
name_edge_left = top[3]
|
|
763
|
-
name_edge_right = top[2]
|
|
764
|
-
name_angle = math.degrees(
|
|
765
|
-
math.atan2(
|
|
766
|
-
name_edge_right[1] - name_edge_left[1],
|
|
767
|
-
name_edge_right[0] - name_edge_left[0],
|
|
768
|
-
)
|
|
769
|
-
)
|
|
770
|
-
edge_angle = name_angle
|
|
771
|
-
|
|
772
|
-
front_lines = [line for line in (front_text, side_text) if line]
|
|
773
823
|
if front_lines:
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
text_transform = (
|
|
777
|
-
f"translate({text_x} {start_y}) rotate({edge_angle}) skewX(30) "
|
|
778
|
-
f"translate({-text_x} {-start_y})"
|
|
824
|
+
text_x, text_y, edge_angle = _iso_front_text_position(
|
|
825
|
+
top_points, tile_w, tile_h
|
|
779
826
|
)
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
827
|
+
_render_iso_text(
|
|
828
|
+
lines,
|
|
829
|
+
text_x=text_x,
|
|
830
|
+
text_y=text_y,
|
|
831
|
+
angle=edge_angle,
|
|
832
|
+
text_lines=front_lines,
|
|
833
|
+
font_size=font_size,
|
|
834
|
+
fill="#555",
|
|
784
835
|
)
|
|
785
|
-
for idx, line in enumerate(front_lines):
|
|
786
|
-
dy = 0 if idx == 0 else line_height
|
|
787
|
-
lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
|
|
788
|
-
lines.append("</text>")
|
|
789
836
|
|
|
790
837
|
if node_type == "ap":
|
|
791
838
|
icon_center_y -= tile_h * 0.4
|
|
@@ -802,25 +849,11 @@ def render_svg_isometric(
|
|
|
802
849
|
)
|
|
803
850
|
|
|
804
851
|
name_font_size = max(options.font_size - 2, 8)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
name_center_y = sum(py for _px, py in top) / len(top)
|
|
811
|
-
name_normal_x = name_center_x - name_mid_x
|
|
812
|
-
name_normal_y = name_center_y - name_mid_y
|
|
813
|
-
name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
|
|
814
|
-
name_normal_x /= name_normal_len
|
|
815
|
-
name_normal_y /= name_normal_len
|
|
816
|
-
name_inset = tile_h * 0.13
|
|
817
|
-
name_x = name_mid_x + name_normal_x * name_inset - tile_w * 0.08
|
|
818
|
-
name_y = name_mid_y + name_normal_y * name_inset + name_font_size - tile_h * 0.05
|
|
819
|
-
name_angle = math.degrees(
|
|
820
|
-
math.atan2(
|
|
821
|
-
name_edge_right[1] - name_edge_left[1],
|
|
822
|
-
name_edge_right[0] - name_edge_left[0],
|
|
823
|
-
)
|
|
852
|
+
name_x, name_y, name_angle = _iso_name_label_position(
|
|
853
|
+
top,
|
|
854
|
+
tile_width=tile_w,
|
|
855
|
+
tile_height=tile_h,
|
|
856
|
+
font_size=name_font_size,
|
|
824
857
|
)
|
|
825
858
|
name_transform = (
|
|
826
859
|
f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared SVG defs and theming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SvgTheme:
|
|
10
|
+
link_standard: tuple[str, str]
|
|
11
|
+
link_poe: tuple[str, str]
|
|
12
|
+
node_gateway: tuple[str, str]
|
|
13
|
+
node_switch: tuple[str, str]
|
|
14
|
+
node_ap: tuple[str, str]
|
|
15
|
+
node_client: tuple[str, str]
|
|
16
|
+
node_other: tuple[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_THEME = SvgTheme(
|
|
20
|
+
link_standard=("#16a085", "#2ecc71"),
|
|
21
|
+
link_poe=("#1e88e5", "#42a5f5"),
|
|
22
|
+
node_gateway=("#ffd199", "#ffb15a"),
|
|
23
|
+
node_switch=("#bfe4ff", "#8ac6ff"),
|
|
24
|
+
node_ap=("#c4f2d4", "#8ee3b4"),
|
|
25
|
+
node_client=("#e4ccff", "#c5a4ff"),
|
|
26
|
+
node_other=("#e3e3e3", "#cfcfcf"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def svg_defs(prefix: str, theme: SvgTheme = DEFAULT_THEME) -> str:
|
|
31
|
+
gradient_prefix = f"{prefix}-" if prefix else ""
|
|
32
|
+
node_prefix = f"{prefix}-node-" if prefix else "node-"
|
|
33
|
+
return (
|
|
34
|
+
"<defs>"
|
|
35
|
+
f'<linearGradient id="{gradient_prefix}link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
36
|
+
f'<stop offset="0%" stop-color="{theme.link_standard[0]}"/>'
|
|
37
|
+
f'<stop offset="100%" stop-color="{theme.link_standard[1]}"/>'
|
|
38
|
+
"</linearGradient>"
|
|
39
|
+
f'<linearGradient id="{gradient_prefix}link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
|
|
40
|
+
f'<stop offset="0%" stop-color="{theme.link_poe[0]}"/>'
|
|
41
|
+
f'<stop offset="100%" stop-color="{theme.link_poe[1]}"/>'
|
|
42
|
+
"</linearGradient>"
|
|
43
|
+
f'<linearGradient id="{node_prefix}gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
44
|
+
f'<stop offset="0%" stop-color="{theme.node_gateway[0]}"/>'
|
|
45
|
+
f'<stop offset="100%" stop-color="{theme.node_gateway[1]}"/>'
|
|
46
|
+
"</linearGradient>"
|
|
47
|
+
f'<linearGradient id="{node_prefix}switch" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
48
|
+
f'<stop offset="0%" stop-color="{theme.node_switch[0]}"/>'
|
|
49
|
+
f'<stop offset="100%" stop-color="{theme.node_switch[1]}"/>'
|
|
50
|
+
"</linearGradient>"
|
|
51
|
+
f'<linearGradient id="{node_prefix}ap" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
52
|
+
f'<stop offset="0%" stop-color="{theme.node_ap[0]}"/>'
|
|
53
|
+
f'<stop offset="100%" stop-color="{theme.node_ap[1]}"/>'
|
|
54
|
+
"</linearGradient>"
|
|
55
|
+
f'<linearGradient id="{node_prefix}client" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
56
|
+
f'<stop offset="0%" stop-color="{theme.node_client[0]}"/>'
|
|
57
|
+
f'<stop offset="100%" stop-color="{theme.node_client[1]}"/>'
|
|
58
|
+
"</linearGradient>"
|
|
59
|
+
f'<linearGradient id="{node_prefix}other" x1="0%" y1="0%" x2="100%" y2="100%">'
|
|
60
|
+
f'<stop offset="0%" stop-color="{theme.node_other[0]}"/>'
|
|
61
|
+
f'<stop offset="100%" stop-color="{theme.node_other[1]}"/>'
|
|
62
|
+
"</linearGradient>"
|
|
63
|
+
"</defs>"
|
|
64
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Theme loading for Mermaid and SVG rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from .mermaid_theme import DEFAULT_THEME as DEFAULT_MERMAID_THEME
|
|
10
|
+
from .mermaid_theme import MermaidTheme
|
|
11
|
+
from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
|
|
12
|
+
from .svg_theme import SvgTheme
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _coerce_pair(value: object, default: tuple[str, str]) -> tuple[str, str]:
|
|
16
|
+
if isinstance(value, list | tuple) and len(value) == 2:
|
|
17
|
+
left, right = value
|
|
18
|
+
if isinstance(left, str) and isinstance(right, str):
|
|
19
|
+
return (left, right)
|
|
20
|
+
if isinstance(value, dict):
|
|
21
|
+
left = value.get("from") or value.get("start")
|
|
22
|
+
right = value.get("to") or value.get("end")
|
|
23
|
+
if isinstance(left, str) and isinstance(right, str):
|
|
24
|
+
return (left, right)
|
|
25
|
+
return default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _coerce_color(value: object, default: str) -> str:
|
|
29
|
+
return value if isinstance(value, str) else default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mermaid_theme_from_dict(data: dict, base: MermaidTheme) -> MermaidTheme:
|
|
33
|
+
nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
|
|
34
|
+
|
|
35
|
+
def _node(name: str) -> tuple[str, str]:
|
|
36
|
+
return (
|
|
37
|
+
_coerce_color(nodes.get(name, {}).get("fill"), getattr(base, f"node_{name}")[0]),
|
|
38
|
+
_coerce_color(nodes.get(name, {}).get("stroke"), getattr(base, f"node_{name}")[1]),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return MermaidTheme(
|
|
42
|
+
node_gateway=_node("gateway"),
|
|
43
|
+
node_switch=_node("switch"),
|
|
44
|
+
node_ap=_node("ap"),
|
|
45
|
+
node_client=_node("client"),
|
|
46
|
+
node_other=_node("other"),
|
|
47
|
+
poe_link=_coerce_color(data.get("poe_link"), base.poe_link),
|
|
48
|
+
poe_link_width=int(data.get("poe_link_width", base.poe_link_width)),
|
|
49
|
+
poe_link_arrow=_coerce_color(data.get("poe_link_arrow"), base.poe_link_arrow),
|
|
50
|
+
standard_link=_coerce_color(data.get("standard_link"), base.standard_link),
|
|
51
|
+
standard_link_width=int(data.get("standard_link_width", base.standard_link_width)),
|
|
52
|
+
standard_link_arrow=_coerce_color(
|
|
53
|
+
data.get("standard_link_arrow"), base.standard_link_arrow
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
|
|
59
|
+
nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
|
|
60
|
+
links = data.get("links", {}) if isinstance(data.get("links"), dict) else {}
|
|
61
|
+
|
|
62
|
+
return SvgTheme(
|
|
63
|
+
link_standard=_coerce_pair(links.get("standard"), base.link_standard),
|
|
64
|
+
link_poe=_coerce_pair(links.get("poe"), base.link_poe),
|
|
65
|
+
node_gateway=_coerce_pair(nodes.get("gateway"), base.node_gateway),
|
|
66
|
+
node_switch=_coerce_pair(nodes.get("switch"), base.node_switch),
|
|
67
|
+
node_ap=_coerce_pair(nodes.get("ap"), base.node_ap),
|
|
68
|
+
node_client=_coerce_pair(nodes.get("client"), base.node_client),
|
|
69
|
+
node_other=_coerce_pair(nodes.get("other"), base.node_other),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
|
|
74
|
+
theme_path = Path(path)
|
|
75
|
+
payload = yaml.safe_load(theme_path.read_text(encoding="utf-8"))
|
|
76
|
+
if not isinstance(payload, dict):
|
|
77
|
+
raise ValueError("Theme file must contain a YAML mapping")
|
|
78
|
+
|
|
79
|
+
mermaid_data = payload.get("mermaid", {})
|
|
80
|
+
svg_data = payload.get("svg", {})
|
|
81
|
+
|
|
82
|
+
mermaid_theme = _mermaid_theme_from_dict(mermaid_data, DEFAULT_MERMAID_THEME)
|
|
83
|
+
svg_theme = _svg_theme_from_dict(svg_data, DEFAULT_SVG_THEME)
|
|
84
|
+
return mermaid_theme, svg_theme
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_themes(theme_file: str | Path | None) -> tuple[MermaidTheme, SvgTheme]:
|
|
88
|
+
if theme_file:
|
|
89
|
+
return load_theme(theme_file)
|
|
90
|
+
return DEFAULT_MERMAID_THEME, DEFAULT_SVG_THEME
|