archi-cli 0.1.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.
- archi_cli-0.1.0.dist-info/METADATA +214 -0
- archi_cli-0.1.0.dist-info/RECORD +16 -0
- archi_cli-0.1.0.dist-info/WHEEL +4 -0
- archi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- archi_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- archi_tool/__init__.py +1 -0
- archi_tool/cli.py +528 -0
- archi_tool/discovery.py +137 -0
- archi_tool/engine.py +266 -0
- archi_tool/model.py +280 -0
- archi_tool/normalize.py +91 -0
- archi_tool/render.py +207 -0
- archi_tool/render_html.py +645 -0
- archi_tool/render_slides.py +650 -0
- archi_tool/validate.py +150 -0
- archi_tool/views.py +215 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Render views to standalone HTML pages styled with the NLDD design system.
|
|
2
|
+
|
|
3
|
+
Unlike the Mermaid renderer this is faithful to the Archi view: it uses the
|
|
4
|
+
layout stored in the model. Bounds of nested diagram objects are relative to
|
|
5
|
+
their parent; this module resolves them to absolute canvas coordinates.
|
|
6
|
+
Boxes are positioned divs, connections an SVG layer clipped to box borders.
|
|
7
|
+
NLDD components and tokens load from the CDN, pinned to a fixed version so
|
|
8
|
+
output stays deterministic.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .model import FOLDER_BY_ELEMENT_TYPE, xsi_type
|
|
16
|
+
from .render import (CONTAINMENT_TYPES, DOTTED_TYPES, LAYER_PALETTE, MARKER,
|
|
17
|
+
display_model_path, is_descendant, slugify,
|
|
18
|
+
write_if_changed)
|
|
19
|
+
|
|
20
|
+
NLDD_VERSION = "0.8.64"
|
|
21
|
+
NLDD_CSS = (f"https://cdn.jsdelivr.net/npm/@nldd/design-system@{NLDD_VERSION}"
|
|
22
|
+
"/dist/css/global.css")
|
|
23
|
+
NLDD_JS = f"https://cdn.jsdelivr.net/npm/@nldd/design-system@{NLDD_VERSION}/+esm"
|
|
24
|
+
|
|
25
|
+
CANVAS_MARGIN = 40
|
|
26
|
+
|
|
27
|
+
LAYER_LABELS = {
|
|
28
|
+
"strategy": "Strategie",
|
|
29
|
+
"business": "Bedrijf",
|
|
30
|
+
"application": "Applicatie",
|
|
31
|
+
"technology": "Technologie",
|
|
32
|
+
"motivation": "Motivatie",
|
|
33
|
+
"implementation_migration": "Implementatie & migratie",
|
|
34
|
+
"other": "Overig",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# two stacked ArchiMate-colored blocks with a connector, as inline data URI
|
|
38
|
+
# so the pages stay fully self-contained ("#" must be encoded as %23)
|
|
39
|
+
FAVICON = (
|
|
40
|
+
"data:image/svg+xml,"
|
|
41
|
+
"%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E"
|
|
42
|
+
"%3Crect x='3' y='4' width='17' height='10' rx='2' fill='%23FAC75A' "
|
|
43
|
+
"stroke='%23D4882A'/%3E"
|
|
44
|
+
"%3Crect x='12' y='19' width='17' height='10' rx='2' fill='%23CECBF6' "
|
|
45
|
+
"stroke='%237F77DD'/%3E"
|
|
46
|
+
"%3Cpath d='M11 14v4h6v1' fill='none' stroke='%235b5b66' "
|
|
47
|
+
"stroke-width='1.5'/%3E"
|
|
48
|
+
"%3C/svg%3E")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# view-only child types (no archimateElement): notes, visual groups and
|
|
52
|
+
# references to other views all render, everything else is skipped
|
|
53
|
+
VIEW_ONLY_KINDS = {"Note": "note", "Group": "group",
|
|
54
|
+
"DiagramModelReference": "ref"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def absolute_boxes(diagram, index) -> list:
|
|
58
|
+
"""Resolve diagram objects to absolute canvas coordinates."""
|
|
59
|
+
boxes = []
|
|
60
|
+
|
|
61
|
+
def walk(objects, offset_x, offset_y):
|
|
62
|
+
for obj in objects:
|
|
63
|
+
element = index.get(obj.get("archimateElement") or "")
|
|
64
|
+
bounds = obj.find("bounds")
|
|
65
|
+
if bounds is None:
|
|
66
|
+
continue
|
|
67
|
+
kind = "element" if element is not None else \
|
|
68
|
+
VIEW_ONLY_KINDS.get(xsi_type(obj))
|
|
69
|
+
if kind is None:
|
|
70
|
+
continue
|
|
71
|
+
x = offset_x + int(bounds.get("x", "0"))
|
|
72
|
+
y = offset_y + int(bounds.get("y", "0"))
|
|
73
|
+
children = obj.findall("child")
|
|
74
|
+
boxes.append({
|
|
75
|
+
"id": obj.get("id"), "element": element, "kind": kind,
|
|
76
|
+
"node": obj,
|
|
77
|
+
"x": x, "y": y,
|
|
78
|
+
"w": int(bounds.get("width", "120")),
|
|
79
|
+
"h": int(bounds.get("height", "55")),
|
|
80
|
+
"container": bool(children),
|
|
81
|
+
})
|
|
82
|
+
walk(children, x, y)
|
|
83
|
+
|
|
84
|
+
walk(diagram.findall("child"), 0, 0)
|
|
85
|
+
return boxes
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def border_point(box, toward_x, toward_y):
|
|
89
|
+
"""Point on the border of an axis-aligned box, from its center toward
|
|
90
|
+
the given target point."""
|
|
91
|
+
center_x = box["x"] + box["w"] / 2
|
|
92
|
+
center_y = box["y"] + box["h"] / 2
|
|
93
|
+
dx, dy = toward_x - center_x, toward_y - center_y
|
|
94
|
+
if dx == 0 and dy == 0:
|
|
95
|
+
return center_x, center_y
|
|
96
|
+
scale_x = (box["w"] / 2) / abs(dx) if dx else float("inf")
|
|
97
|
+
scale_y = (box["h"] / 2) / abs(dy) if dy else float("inf")
|
|
98
|
+
scale = min(scale_x, scale_y)
|
|
99
|
+
return center_x + dx * scale, center_y + dy * scale
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def diagram_edges(model, diagram, box_by_object_id) -> list:
|
|
103
|
+
index = model.id_index()
|
|
104
|
+
edges = []
|
|
105
|
+
for conn in diagram.iter("sourceConnection"):
|
|
106
|
+
source = box_by_object_id.get(conn.get("source"))
|
|
107
|
+
target = box_by_object_id.get(conn.get("target"))
|
|
108
|
+
if not source or not target:
|
|
109
|
+
continue
|
|
110
|
+
rel = index.get(conn.get("archimateRelationship") or "")
|
|
111
|
+
rel_type = xsi_type(rel) if rel is not None else ""
|
|
112
|
+
source_obj = index.get(conn.get("source"))
|
|
113
|
+
target_obj = index.get(conn.get("target"))
|
|
114
|
+
if (rel_type in CONTAINMENT_TYPES
|
|
115
|
+
and is_descendant(target_obj, source_obj)):
|
|
116
|
+
continue
|
|
117
|
+
source_cx = source["x"] + source["w"] / 2
|
|
118
|
+
source_cy = source["y"] + source["h"] / 2
|
|
119
|
+
target_cx = target["x"] + target["w"] / 2
|
|
120
|
+
target_cy = target["y"] + target["h"] / 2
|
|
121
|
+
# bendpoint offsets are stored relative to the source center
|
|
122
|
+
waypoints = [
|
|
123
|
+
(source_cx + int(bp.get("startX", "0")),
|
|
124
|
+
source_cy + int(bp.get("startY", "0")))
|
|
125
|
+
for bp in conn.findall("bendpoint")]
|
|
126
|
+
first = waypoints[0] if waypoints else (target_cx, target_cy)
|
|
127
|
+
last = waypoints[-1] if waypoints else (source_cx, source_cy)
|
|
128
|
+
x1, y1 = border_point(source, *first)
|
|
129
|
+
x2, y2 = border_point(target, *last)
|
|
130
|
+
label = (rel.get("name") if rel is not None else None) or rel_type
|
|
131
|
+
edges.append({
|
|
132
|
+
"points": [(x1, y1), *waypoints, (x2, y2)], "label": label,
|
|
133
|
+
"source_id": conn.get("source"), "target_id": conn.get("target"),
|
|
134
|
+
"dotted": rel_type in DOTTED_TYPES,
|
|
135
|
+
"containment": rel_type in CONTAINMENT_TYPES,
|
|
136
|
+
# a connection without relationship attaches a note: dotted, no
|
|
137
|
+
# arrowhead, exactly as Archi draws it
|
|
138
|
+
"note_link": rel is None,
|
|
139
|
+
})
|
|
140
|
+
return edges
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def layer_of(element) -> str:
|
|
144
|
+
return FOLDER_BY_ELEMENT_TYPE.get(xsi_type(element), "other")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# small "mini view" glyph for references to another view
|
|
148
|
+
VIEW_REF_ICON = (
|
|
149
|
+
'<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
|
|
150
|
+
'stroke="currentColor" aria-hidden="true">'
|
|
151
|
+
'<rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/>'
|
|
152
|
+
'<rect x="4" y="5" width="3.4" height="2.6"/>'
|
|
153
|
+
'<rect x="8.8" y="8.6" width="3.4" height="2.6"/>'
|
|
154
|
+
'<path d="M7.4 6.3h3.1v2.3"/></svg>')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def element_icon(element) -> str:
|
|
158
|
+
"""ArchiMate notation icon (nested notation: top-right of the box)."""
|
|
159
|
+
glyph = ICON_GLYPHS.get(ICON_BY_TYPE.get(xsi_type(element), ""))
|
|
160
|
+
if not glyph:
|
|
161
|
+
return ""
|
|
162
|
+
_, stroke, _ = LAYER_PALETTE[layer_of(element)]
|
|
163
|
+
return (f'<svg class="type-icon" viewBox="0 0 16 16" width="14" '
|
|
164
|
+
f'height="14" fill="none" stroke="currentColor" '
|
|
165
|
+
f'style="color:{stroke}" aria-hidden="true">{glyph}</svg>')
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# behavior glyphs repeat across layers; every element type maps to a glyph
|
|
169
|
+
ICON_BY_TYPE = {
|
|
170
|
+
# strategy
|
|
171
|
+
"Resource": "Resource", "Capability": "Capability",
|
|
172
|
+
"CourseOfAction": "CourseOfAction", "ValueStream": "ValueStream",
|
|
173
|
+
# business
|
|
174
|
+
"BusinessActor": "BusinessActor", "BusinessRole": "BusinessRole",
|
|
175
|
+
"BusinessCollaboration": "Collaboration",
|
|
176
|
+
"BusinessInterface": "Interface", "BusinessProcess": "Process",
|
|
177
|
+
"BusinessFunction": "Function", "BusinessInteraction": "Interaction",
|
|
178
|
+
"BusinessEvent": "Event", "BusinessService": "Service",
|
|
179
|
+
"BusinessObject": "Object", "Contract": "Contract",
|
|
180
|
+
"Representation": "Representation", "Product": "Product",
|
|
181
|
+
# application
|
|
182
|
+
"ApplicationComponent": "ApplicationComponent",
|
|
183
|
+
"ApplicationCollaboration": "Collaboration",
|
|
184
|
+
"ApplicationInterface": "Interface", "ApplicationFunction": "Function",
|
|
185
|
+
"ApplicationInteraction": "Interaction",
|
|
186
|
+
"ApplicationProcess": "Process", "ApplicationEvent": "Event",
|
|
187
|
+
"ApplicationService": "Service", "DataObject": "Object",
|
|
188
|
+
# technology & physical
|
|
189
|
+
"Node": "Node", "Device": "Device", "SystemSoftware": "SystemSoftware",
|
|
190
|
+
"TechnologyCollaboration": "Collaboration",
|
|
191
|
+
"TechnologyInterface": "Interface", "Path": "Path",
|
|
192
|
+
"CommunicationNetwork": "CommunicationNetwork",
|
|
193
|
+
"TechnologyFunction": "Function", "TechnologyProcess": "Process",
|
|
194
|
+
"TechnologyInteraction": "Interaction", "TechnologyEvent": "Event",
|
|
195
|
+
"TechnologyService": "Service", "Artifact": "Artifact",
|
|
196
|
+
"Material": "Material", "Equipment": "Equipment",
|
|
197
|
+
"Facility": "Facility", "DistributionNetwork": "DistributionNetwork",
|
|
198
|
+
# motivation
|
|
199
|
+
"Stakeholder": "Stakeholder", "Driver": "Driver",
|
|
200
|
+
"Assessment": "Assessment", "Goal": "Goal", "Outcome": "Outcome",
|
|
201
|
+
"Principle": "Principle", "Requirement": "Requirement",
|
|
202
|
+
"Constraint": "Constraint", "Meaning": "Meaning", "Value": "Value",
|
|
203
|
+
# implementation & migration
|
|
204
|
+
"WorkPackage": "WorkPackage", "Deliverable": "Deliverable",
|
|
205
|
+
"ImplementationEvent": "Event", "Plateau": "Plateau", "Gap": "Gap",
|
|
206
|
+
# other
|
|
207
|
+
"Location": "Location", "Grouping": "Grouping", "Junction": "Junction",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# inner SVG per glyph for a 16x16 viewBox, ported from the drawIcon()
|
|
211
|
+
# methods in Archi's figure classes (stroke follows the layer line color)
|
|
212
|
+
ICON_GLYPHS = {
|
|
213
|
+
# Resource: rounded rect body + right nub + 3 vertical lines
|
|
214
|
+
"Resource": '<g fill="none" stroke="currentColor"><rect x="1" y="3.9" width="12.4" height="8.2" rx="1.2"/><rect x="13.4" y="6.4" width="1.6" height="3.3" rx="0.4"/><line x1="3.5" y1="5.5" x2="3.5" y2="10.5"/><line x1="5.9" y1="5.5" x2="5.9" y2="10.5"/><line x1="8.4" y1="5.5" x2="8.4" y2="10.5"/></g>',
|
|
215
|
+
# Capability: staircase of six squares
|
|
216
|
+
"Capability": '<g fill="none" stroke="currentColor"><rect x="10.3" y="1" width="4.7" height="4.7"/><rect x="5.7" y="5.7" width="4.6" height="4.6"/><rect x="10.3" y="5.7" width="4.7" height="4.6"/><rect x="1" y="10.3" width="4.7" height="4.7"/><rect x="5.7" y="10.3" width="4.6" height="4.7"/><rect x="10.3" y="10.3" width="4.7" height="4.7"/></g>',
|
|
217
|
+
# CourseOfAction: target circles + arrow toward the bullseye
|
|
218
|
+
"CourseOfAction": '<g fill="none" stroke="currentColor"><polygon points="2.4,8.6 6.5,9.3 4.4,12.9" fill="currentColor" stroke="none"/><path d="M4.4 10.7 A3.4 3.4 0 0 0 1 13.5"/><circle cx="10.5" cy="6.9" r="4.5"/><circle cx="10.5" cy="6.9" r="2.7"/><circle cx="10.5" cy="6.9" r="1"/><circle cx="10.5" cy="6.9" r="0.3"/></g>',
|
|
219
|
+
# ValueStream: chevron
|
|
220
|
+
"ValueStream": '<polygon points="1,3.3 10.3,3.3 15,8 10.3,12.7 1,12.7 5.7,8" fill="none" stroke="currentColor"/>',
|
|
221
|
+
# ApplicationComponent: rectangle with two protruding nubs
|
|
222
|
+
"ApplicationComponent": '<g fill="none" stroke="currentColor"><path d="M4.2 15L4.2 10.7M4.2 8.5L4.2 6.4M4.2 3.2L4.2 1L15 1L15 15L3.7 15"/><rect x="1" y="3.2" width="6.5" height="2.7"/><rect x="1" y="8.5" width="6.5" height="2.7"/></g>',
|
|
223
|
+
# Node: 3D box
|
|
224
|
+
"Node": '<g fill="none" stroke="currentColor"><rect x="1.2" y="4" width="10.8" height="10.8"/><path d="M1 4L4.4 1L15 1L15 11.8L12 15M12 4L15 1"/></g>',
|
|
225
|
+
# Device: rounded screen on a foot
|
|
226
|
+
"Device": '<g fill="none" stroke="currentColor"><rect x="2.1" y="1.5" width="11.8" height="8.6" rx="1.6"/><polygon points="1,14.5 4.2,10.2 11.8,10.2 15,14.5"/></g>',
|
|
227
|
+
# SystemSoftware: two overlapping circles
|
|
228
|
+
"SystemSoftware": '<g fill="none" stroke="currentColor"><circle cx="6.9" cy="9.1" r="5.9"/><path d="M12 12.1A5.9 5.9 0 1 0 3.9 4"/></g>',
|
|
229
|
+
# Path: dashed line between two arrowheads
|
|
230
|
+
"Path": '<g fill="none" stroke="currentColor"><path d="M3.9 8L5.5 8M7.2 8L8.8 8M10.5 8L12.1 8"/><path d="M5.1 3.9L1 8L5.1 12.1M10.9 3.9L15 8L10.9 12.1"/></g>',
|
|
231
|
+
# CommunicationNetwork: four linked nodes
|
|
232
|
+
"CommunicationNetwork": '<g fill="none" stroke="currentColor"><circle cx="3.3" cy="11.7" r="2.3"/><circle cx="5.2" cy="4.3" r="2.3"/><circle cx="12.7" cy="4.3" r="2.3"/><circle cx="10.8" cy="11.7" r="2.3"/><path d="M3.8 9.4L4.7 6.6M11.3 9.4L12.2 6.6M5.7 11.7L8.5 11.7M7.5 4.3L10.3 4.3"/></g>',
|
|
233
|
+
# BusinessActor: stick figure
|
|
234
|
+
"BusinessActor": '<ellipse cx="8" cy="3.5" rx="2.5" ry="2.5" stroke="currentColor" fill="none"/><line x1="8" y1="5.9" x2="8" y2="10.9" stroke="currentColor"/><line x1="8" y1="10.9" x2="4.7" y2="15" stroke="currentColor"/><line x1="8" y1="10.9" x2="11.3" y2="15" stroke="currentColor"/><line x1="4.7" y1="8.4" x2="11.3" y2="8.4" stroke="currentColor"/>',
|
|
235
|
+
# BusinessRole: cylinder on its side
|
|
236
|
+
"BusinessRole": '<path d="M3.3 4.3 A2.3 3.7 0 0 0 3.3 11.7 L12.2 11.7 M2.9 4.3 L12.2 4.3" stroke="currentColor" fill="none"/><ellipse cx="12.7" cy="8" rx="2.3" ry="3.7" stroke="currentColor" fill="none"/>',
|
|
237
|
+
# Object: rectangle with title band
|
|
238
|
+
"Object": '<rect x="1" y="2.6" width="14" height="10.8" stroke="currentColor" fill="none"/><line x1="1" y1="5.8" x2="15" y2="5.8" stroke="currentColor"/>',
|
|
239
|
+
# Contract: object with bottom band
|
|
240
|
+
"Contract": '<rect x="1" y="2.6" width="14" height="10.8" stroke="currentColor" fill="none"/><line x1="1" y1="5.8" x2="15" y2="5.8" stroke="currentColor"/><line x1="1" y1="10.2" x2="15" y2="10.2" stroke="currentColor"/>',
|
|
241
|
+
# Representation: rectangle with wavy bottom
|
|
242
|
+
"Representation": '<path d="M1 3.1 L1 10.6 Q4 14.6 9 11.6 Q12 9.1 15 11.6 L15 3.1 Z" stroke="currentColor" fill="none"/>',
|
|
243
|
+
# Product: rectangle with corner tab
|
|
244
|
+
"Product": '<rect x="1" y="2.6" width="14" height="10.8" stroke="currentColor" fill="none"/><rect x="1" y="2.6" width="6.5" height="3.2" stroke="currentColor" fill="none"/>',
|
|
245
|
+
# Process: fat arrow
|
|
246
|
+
"Process": '<polygon points="1,6 9,6 9,3 15,8 9,13 9,10 1,10" fill="none" stroke="currentColor"/>',
|
|
247
|
+
# Function: arrow-tent
|
|
248
|
+
"Function": '<polygon points="2,15 2,6 8,1 14,6 14,15 8,9" fill="none" stroke="currentColor"/>',
|
|
249
|
+
# Interaction: split ellipse halves
|
|
250
|
+
"Interaction": '<path d="M6.5 2 A5 6 0 0 0 6.5 14 L6.5 1.5" fill="none" stroke="currentColor"/><path d="M9.5 14 A5 6 0 0 0 9.5 2 L9.5 14.5" fill="none" stroke="currentColor"/>',
|
|
251
|
+
# Event: open-ended rounded arrow
|
|
252
|
+
"Event": '<path d="M1 4.1 L1 11.9 A3.5 3.9 0 0 0 1 4.1 M11.5 11.9 A3.5 3.9 0 0 0 11.5 4.1 M1 4.1 L11.5 4.1 M1 11.9 L11.5 11.9" fill="none" stroke="currentColor"/>',
|
|
253
|
+
# Service: pill
|
|
254
|
+
"Service": '<rect x="1" y="4.1" width="14" height="7.9" rx="3.5" ry="3.5" fill="none" stroke="currentColor"/>',
|
|
255
|
+
# Collaboration: two overlapping circles
|
|
256
|
+
"Collaboration": '<circle cx="6" cy="8" r="5" fill="none" stroke="currentColor"/><circle cx="10" cy="8" r="5" fill="none" stroke="currentColor"/>',
|
|
257
|
+
# Interface: lollipop
|
|
258
|
+
"Interface": '<circle cx="10.9" cy="8" r="4.1" fill="none" stroke="currentColor"/><line x1="6.8" y1="8" x2="1" y2="8" stroke="currentColor"/>',
|
|
259
|
+
# Artifact: document with folded corner
|
|
260
|
+
"Artifact": '<path d="M2.4 1 L8.9 1 L13.6 5.7 L13.6 15 L2.4 15 Z M8.9 1 L8.9 5.7 L13.6 5.7" fill="none" stroke="currentColor"/>',
|
|
261
|
+
# Material: hexagon with inner marks
|
|
262
|
+
"Material": '<polygon points="11.5,1.9 4.5,1.9 1,8 3.6,14.1 11.5,14.1 15,8" fill="none" stroke="currentColor"/><path d="M6.2 3.6 L3.4 8.4 M4.8 11.9 L10.6 11.9 M12.4 8.4 L9.8 3.6" fill="none" stroke="currentColor"/>',
|
|
263
|
+
# Equipment: two cogs
|
|
264
|
+
"Equipment": '<polygon points="10.9,11 12.1,11 12.1,9.6 10.9,9.6 10.4,8.3 11.2,7.5 10.2,6.5 9.4,7.4 8.1,6.8 8.1,5.6 6.7,5.6 6.7,6.8 5.4,7.4 4.6,6.5 3.6,7.5 4.5,8.3 3.9,9.6 2.7,9.6 2.7,11 3.9,11 4.5,12.3 3.6,13.1 4.6,14.1 5.4,13.3 6.7,13.8 6.7,15 8.1,15 8.1,13.8 9.4,13.3 10.2,14.1 11.2,13.1 10.4,12.3" fill="none" stroke="currentColor"/><circle cx="7.4" cy="10.3" r="1.8" fill="none" stroke="currentColor"/><polygon points="12.7,4.4 13.3,4.4 13.3,3.2 12.7,3.2 12,2.1 12.3,1.6 11.3,1 11,1.5 9.8,1.5 9.4,1 8.4,1.6 8.7,2.1 8.1,3.2 7.5,3.2 7.5,4.4 8.1,4.4 8.7,5.5 8.4,6 9.4,6.6 9.8,6.1 11,6.1 11.3,6.6 12.3,6 12,5.5" fill="none" stroke="currentColor"/><circle cx="10.4" cy="3.8" r="1.2" fill="none" stroke="currentColor"/>',
|
|
265
|
+
# Facility: factory with sawtooth roof
|
|
266
|
+
"Facility": '<polygon points="1,13.6 15,13.6 15,8 11.3,10.8 11.3,8 7.5,10.8 7.5,8 3.8,10.8 3.8,2.4 1,2.4" fill="none" stroke="currentColor"/>',
|
|
267
|
+
# DistributionNetwork: double arrow
|
|
268
|
+
"DistributionNetwork": '<path d="M2.6 6.4 L13.4 6.4 M2.6 9.6 L13.4 9.6 M5.1 3.9 L1 8 L5.1 12.1 M10.9 3.9 L15 8 L10.9 12.1" fill="none" stroke="currentColor"/>',
|
|
269
|
+
# Stakeholder: cylinder with circle (role variant)
|
|
270
|
+
"Stakeholder": '<path d="M4.7 4.7A3.7 3.3 0 0 0 4.7 11.3L11.3 11.3M4.3 4.7L11.3 4.7"/><ellipse cx="11.7" cy="8" rx="3.3" ry="3.3"/>',
|
|
271
|
+
# Driver: steering wheel
|
|
272
|
+
"Driver": '<ellipse cx="8" cy="8" rx="5.4" ry="5.4"/><ellipse cx="8" cy="8" rx="1.2" ry="1.2" fill="currentColor"/><line x1="1" y1="8" x2="15" y2="8"/><line x1="8" y1="1" x2="8" y2="15"/><line x1="3.1" y1="3.1" x2="12.9" y2="12.9"/><line x1="3.1" y1="12.9" x2="12.9" y2="3.1"/>',
|
|
273
|
+
# Assessment: magnifying glass
|
|
274
|
+
"Assessment": '<ellipse cx="9.8" cy="5.7" rx="4.7" ry="4.7"/><line x1="7.4" y1="9.2" x2="1.6" y2="15"/>',
|
|
275
|
+
# Goal: target with filled bullseye
|
|
276
|
+
"Goal": '<ellipse cx="8" cy="8" rx="7" ry="7"/><ellipse cx="8" cy="8" rx="4.3" ry="4.3"/><ellipse cx="8" cy="8" rx="1.6" ry="1.6" fill="currentColor"/>',
|
|
277
|
+
# Outcome: target with arrow in the bullseye
|
|
278
|
+
"Outcome": '<ellipse cx="6.1" cy="9.9" rx="5.1" ry="5.1"/><ellipse cx="6.1" cy="9.9" rx="3.1" ry="3.1"/><ellipse cx="6.1" cy="9.9" rx="1.2" ry="1.2" fill="currentColor"/><line x1="5.7" y1="10.3" x2="13.1" y2="2.9"/><line x1="11.1" y1="4.9" x2="11.9" y2="1"/><line x1="11.1" y1="4.9" x2="15" y2="4.1"/>',
|
|
279
|
+
# Principle: rounded rectangle with exclamation mark
|
|
280
|
+
"Principle": '<rect x="2" y="1" width="12" height="14" rx="2"/><line x1="7.5" y1="3" x2="7.5" y2="10"/><line x1="8.5" y1="3" x2="8.5" y2="10"/><line x1="7.5" y1="11.5" x2="7.5" y2="13.5"/><line x1="8.5" y1="11.5" x2="8.5" y2="13.5"/>',
|
|
281
|
+
# Requirement: parallelogram
|
|
282
|
+
"Requirement": '<polygon points="4.5,4.1 15,4.1 11.5,11.9 1,11.9"/>',
|
|
283
|
+
# Constraint: parallelogram with slash
|
|
284
|
+
"Constraint": '<polygon points="4.5,4.1 15,4.1 11.5,11.9 1,11.9"/><line x1="8" y1="4.1" x2="4.5" y2="11.9"/>',
|
|
285
|
+
# Meaning: cloud
|
|
286
|
+
"Meaning": '<path d="M8.6 3.4A5.1 3.8 0 0 0 1.6 8.6M13.9 9.1A5.1 3.8 0 0 0 7.4 3.4M7.7 12A3.8 3.2 0 0 1 1.6 8.2M13.7 8.8A3.8 3.8 0 0 1 7.5 12.2"/>',
|
|
287
|
+
# Value: wide ellipse
|
|
288
|
+
"Value": '<ellipse cx="8" cy="8" rx="7" ry="4.5"/>',
|
|
289
|
+
# WorkPackage: circular arrow
|
|
290
|
+
"WorkPackage": '<path d="M9.1 8 A4.2 4.2 0 1 0 5.6 10.8"/><line x1="5.2" y1="10.8" x2="11.3" y2="10.8"/><polygon points="11.3,8 15,10.8 11.3,13.6" fill="currentColor"/>',
|
|
291
|
+
# Deliverable: rectangle with wavy bottom
|
|
292
|
+
"Deliverable": '<path d="M1 3.1 L1 10.6 Q4 14.6 9 11.6 Q12 9.1 15 11.6 L15 3.1 Z"/>',
|
|
293
|
+
# Plateau: three staggered lines
|
|
294
|
+
"Plateau": '<line x1="4.5" y1="5.4" x2="15" y2="5.4"/><line x1="2.8" y1="8" x2="13.3" y2="8"/><line x1="1" y1="10.6" x2="11.5" y2="10.6"/>',
|
|
295
|
+
# Gap: circle crossed by two lines
|
|
296
|
+
"Gap": '<ellipse cx="8" cy="8" rx="5.4" ry="5.4"/><line x1="1" y1="6.8" x2="15" y2="6.8"/><line x1="1" y1="9.2" x2="15" y2="9.2"/>',
|
|
297
|
+
# Location: map pin
|
|
298
|
+
"Location": '<path d="M12.4 7.3 A4.7 4.7 0 1 0 3.6 7.3 L8 15 Z"/>',
|
|
299
|
+
# Grouping: rectangle with corner tab
|
|
300
|
+
"Grouping": '<rect x="1" y="2.6" width="6.5" height="3.2"/><rect x="1" y="5.8" width="14" height="7.5"/>',
|
|
301
|
+
# Junction: connectors meeting in a filled dot
|
|
302
|
+
"Junction": '<rect x="1" y="2" width="2" height="2"/><rect x="1" y="12" width="2" height="2"/><rect x="13" y="7" width="2" height="2"/><line x1="3" y1="4" x2="5" y2="6"/><line x1="9" y1="8" x2="13" y2="8"/><line x1="3" y1="12" x2="5" y2="10"/><ellipse cx="7" cy="8" rx="3" ry="3" fill="currentColor"/>',
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def legend_html(boxes, edges) -> str:
|
|
307
|
+
"""Legend chips for the layers and line styles present in the view.
|
|
308
|
+
Lives inside the always-light canvas, hence the fixed colors."""
|
|
309
|
+
parts = []
|
|
310
|
+
for layer in sorted({layer_of(b["element"]) for b in boxes
|
|
311
|
+
if b["kind"] == "element"}):
|
|
312
|
+
fill, stroke, _ = LAYER_PALETTE[layer]
|
|
313
|
+
parts.append(
|
|
314
|
+
f'<span class="legend-item"><span class="swatch" '
|
|
315
|
+
f'style="background:{fill};border-color:{stroke}"></span>'
|
|
316
|
+
f'{LAYER_LABELS.get(layer, layer)}</span>')
|
|
317
|
+
if any(e["containment"] for e in edges):
|
|
318
|
+
parts.append(
|
|
319
|
+
'<span class="legend-item"><svg width="26" height="10" '
|
|
320
|
+
'viewBox="0 0 26 10" aria-hidden="true">'
|
|
321
|
+
'<line x1="8" y1="5" x2="26" y2="5" class="edge"/>'
|
|
322
|
+
'<path d="M1,5 L5,2 L9,5 L5,8 z" fill="#ffffff" '
|
|
323
|
+
'stroke="#5b5b66"/></svg>bevat</span>')
|
|
324
|
+
if any(e["dotted"] for e in edges):
|
|
325
|
+
parts.append(
|
|
326
|
+
'<span class="legend-item"><svg width="26" height="10" '
|
|
327
|
+
'viewBox="0 0 26 10" aria-hidden="true">'
|
|
328
|
+
'<line x1="0" y1="5" x2="20" y2="5" class="edge dotted"/>'
|
|
329
|
+
'<path d="M19,2 L26,5 L19,8 z" fill="#5b5b66"/></svg>'
|
|
330
|
+
'beïnvloedt of realiseert</span>')
|
|
331
|
+
if any(not e["dotted"] and not e["containment"] and not e["note_link"]
|
|
332
|
+
for e in edges):
|
|
333
|
+
parts.append(
|
|
334
|
+
'<span class="legend-item"><svg width="26" height="10" '
|
|
335
|
+
'viewBox="0 0 26 10" aria-hidden="true">'
|
|
336
|
+
'<line x1="0" y1="5" x2="20" y2="5" class="edge"/>'
|
|
337
|
+
'<path d="M19,2 L26,5 L19,8 z" fill="#5b5b66"/></svg>'
|
|
338
|
+
'overige relatie</span>')
|
|
339
|
+
if not parts:
|
|
340
|
+
return ""
|
|
341
|
+
return '<div class="legend">' + "".join(parts) + "</div>"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def view_thumbnail_svg(boxes) -> str:
|
|
345
|
+
"""Miniature of the view layout: colored rectangles, no text."""
|
|
346
|
+
if not boxes:
|
|
347
|
+
return ""
|
|
348
|
+
width = max(b["x"] + b["w"] for b in boxes)
|
|
349
|
+
height = max(b["y"] + b["h"] for b in boxes)
|
|
350
|
+
rects = []
|
|
351
|
+
for box in boxes:
|
|
352
|
+
if box["kind"] == "element":
|
|
353
|
+
fill, stroke, _ = LAYER_PALETTE[layer_of(box["element"])]
|
|
354
|
+
else:
|
|
355
|
+
fill, stroke = "#ECECF1", "#9B9BA4" # notes/groups/refs: neutral
|
|
356
|
+
opacity = ' fill-opacity="0.35"' if box["container"] else ""
|
|
357
|
+
rects.append(
|
|
358
|
+
f'<rect x="{box["x"]}" y="{box["y"]}" width="{box["w"]}" '
|
|
359
|
+
f'height="{box["h"]}" rx="6" fill="{fill}"{opacity} '
|
|
360
|
+
f'stroke="{stroke}" vector-effect="non-scaling-stroke"/>')
|
|
361
|
+
return (f'<svg class="thumb-svg" viewBox="-12 -12 {width + 24} '
|
|
362
|
+
f'{height + 24}" preserveAspectRatio="xMidYMid meet" '
|
|
363
|
+
f'aria-hidden="true">{"".join(rects)}</svg>')
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def layer_css() -> str:
|
|
367
|
+
rules = []
|
|
368
|
+
for layer, (fill, stroke, text) in LAYER_PALETTE.items():
|
|
369
|
+
rules.append(
|
|
370
|
+
f" .leaf.{layer} {{ background: {fill}; "
|
|
371
|
+
f"border-color: {stroke}; color: {text}; }}")
|
|
372
|
+
rules.append(
|
|
373
|
+
f" .container.{layer} {{ background: "
|
|
374
|
+
f"color-mix(in srgb, {fill} 22%, transparent); "
|
|
375
|
+
f"border-color: {stroke}; color: {text}; }}")
|
|
376
|
+
return "\n".join(rules)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# shared between the view pages and the slide decks (render_slides.py)
|
|
380
|
+
DIAGRAM_CSS = """\
|
|
381
|
+
/* the diagram canvas is always light, like an image: the ArchiMate
|
|
382
|
+
palette is designed for a light surface */
|
|
383
|
+
.diagram-wrap { overflow-x: auto; border-radius: 12px;
|
|
384
|
+
border: 1px solid var(--primitives-color-neutral-200);
|
|
385
|
+
background: #ffffff; }
|
|
386
|
+
.diagram { position: relative; }
|
|
387
|
+
.diagram > svg { position: absolute; inset: 0; }
|
|
388
|
+
.edge { stroke: #5b5b66; stroke-width: 1.3; }
|
|
389
|
+
.edge.dotted { stroke-dasharray: 5 3; }
|
|
390
|
+
.box { position: absolute; box-sizing: border-box;
|
|
391
|
+
font-size: 13px; line-height: 1.3; }
|
|
392
|
+
.leaf { display: flex; align-items: center; justify-content: center;
|
|
393
|
+
text-align: center; padding: 4px 10px; border-radius: 6px;
|
|
394
|
+
border: 1px solid; box-shadow: 0 1px 2px rgb(0 0 0 / 0.10);
|
|
395
|
+
transition: box-shadow 0.15s ease; }
|
|
396
|
+
.leaf:hover { box-shadow: 0 3px 10px rgb(0 0 0 / 0.20); }
|
|
397
|
+
.container { border-radius: 10px; border: 1.5px solid;
|
|
398
|
+
padding: 10px 14px; font-weight: 550; }
|
|
399
|
+
.type-icon { position: absolute; top: 3px; right: 4px;
|
|
400
|
+
pointer-events: none; }
|
|
401
|
+
/* notes, groups and view references live on the light canvas too */
|
|
402
|
+
.note { background: #FEFCF0; border: 1px solid #9B9BA4; color: #3A3A40;
|
|
403
|
+
padding: 6px 9px; font-size: 12px; text-align: left;
|
|
404
|
+
white-space: pre-wrap;
|
|
405
|
+
clip-path: polygon(0 0, 100% 0, 100% calc(100% - 11px),
|
|
406
|
+
calc(100% - 11px) 100%, 0 100%); }
|
|
407
|
+
.group { border: 1px solid #9B9BA4; background: #F4F4F7; color: #3A3A40;
|
|
408
|
+
padding: 4px 10px; font-weight: 550; text-align: left; }
|
|
409
|
+
.ref { display: flex; align-items: center; justify-content: center;
|
|
410
|
+
gap: 6px; padding: 4px 10px; border-radius: 6px;
|
|
411
|
+
border: 1px solid #9B9BA4; background: #ECECF1; color: #444451; }
|
|
412
|
+
.ref a { color: inherit; }
|
|
413
|
+
.ref svg { flex: none; }
|
|
414
|
+
/* legend sits inside the light canvas: fixed light-surface colors */
|
|
415
|
+
.legend { display: flex; flex-wrap: wrap; gap: 6px 18px;
|
|
416
|
+
align-items: center; padding: 10px 14px;
|
|
417
|
+
border-top: 1px solid #e6e6eb; font-size: 12.5px; color: #55555e; }
|
|
418
|
+
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
|
|
419
|
+
.legend-item svg { flex: none; }
|
|
420
|
+
.swatch { width: 12px; height: 12px; border-radius: 3px;
|
|
421
|
+
border: 1px solid; display: inline-block; flex: none; }
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
PAGE_CSS = """
|
|
425
|
+
/* NLDD primitives are light-dark() pairs themselves: use a single token
|
|
426
|
+
and it follows the color scheme — never wrap them in light-dark(). */
|
|
427
|
+
.doc { max-width: 72ch; color: var(--primitives-color-neutral-700); }
|
|
428
|
+
""" + DIAGRAM_CSS + """\
|
|
429
|
+
.card-link { text-decoration: none; color: inherit; display: block;
|
|
430
|
+
height: 100%; }
|
|
431
|
+
/* thumbnails mirror the diagram canvas: always light */
|
|
432
|
+
.thumb { background: #ffffff; border: 1px solid #e6e6eb;
|
|
433
|
+
border-radius: 8px; height: 150px; padding: 10px;
|
|
434
|
+
display: flex; align-items: center; justify-content: center; }
|
|
435
|
+
.thumb-svg { width: 100%; height: 100%; }
|
|
436
|
+
.meta { color: var(--primitives-color-neutral-600); font-size: 14px; }
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def page_shell(title: str, body: str) -> str:
|
|
441
|
+
return f"""{MARKER}
|
|
442
|
+
<!doctype html>
|
|
443
|
+
<html lang="nl">
|
|
444
|
+
<head>
|
|
445
|
+
<meta charset="utf-8">
|
|
446
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
447
|
+
<title>{html.escape(title)}</title>
|
|
448
|
+
<link rel="icon" href="{FAVICON}">
|
|
449
|
+
<link rel="stylesheet" href="{NLDD_CSS}">
|
|
450
|
+
<script type="module">import "{NLDD_JS}";</script>
|
|
451
|
+
<style>
|
|
452
|
+
{layer_css()}
|
|
453
|
+
{PAGE_CSS}
|
|
454
|
+
</style>
|
|
455
|
+
</head>
|
|
456
|
+
<body>
|
|
457
|
+
<nldd-app-view>
|
|
458
|
+
<nldd-page>
|
|
459
|
+
<nldd-simple-section width="full">
|
|
460
|
+
{body}
|
|
461
|
+
</nldd-simple-section>
|
|
462
|
+
</nldd-page>
|
|
463
|
+
</nldd-app-view>
|
|
464
|
+
</body>
|
|
465
|
+
</html>
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def diagram_canvas(model, diagram, marker_prefix: str = "",
|
|
470
|
+
ref_base: str = "", dim_ids: set | None = None) -> dict:
|
|
471
|
+
"""Edge SVG and positioned box divs for one diagram, plus metadata.
|
|
472
|
+
|
|
473
|
+
marker_prefix keeps the SVG marker ids unique when several diagrams
|
|
474
|
+
share one document (the slide decks embed many); ref_base prefixes the
|
|
475
|
+
links of view-reference boxes so they resolve from other directories;
|
|
476
|
+
dim_ids marks diagram objects (and edges touching them) with a "dim"
|
|
477
|
+
class so a slide can spotlight the rest.
|
|
478
|
+
"""
|
|
479
|
+
index = model.id_index()
|
|
480
|
+
boxes = absolute_boxes(diagram, index)
|
|
481
|
+
box_by_object_id = {b["id"]: b for b in boxes}
|
|
482
|
+
edges = diagram_edges(model, diagram, box_by_object_id)
|
|
483
|
+
dim_ids = dim_ids or set()
|
|
484
|
+
|
|
485
|
+
width = max((b["x"] + b["w"] for b in boxes), default=0) + CANVAS_MARGIN
|
|
486
|
+
height = max((b["y"] + b["h"] for b in boxes), default=0) + CANVAS_MARGIN
|
|
487
|
+
|
|
488
|
+
svg = [f'<svg width="{width}" height="{height}" '
|
|
489
|
+
f'viewBox="0 0 {width} {height}">',
|
|
490
|
+
'<defs>',
|
|
491
|
+
f'<marker id="{marker_prefix}arrow" markerWidth="10" '
|
|
492
|
+
'markerHeight="8" refX="9" '
|
|
493
|
+
'refY="4" orient="auto" markerUnits="userSpaceOnUse">'
|
|
494
|
+
'<path d="M0,0 L10,4 L0,8 z" fill="#5b5b66"/></marker>',
|
|
495
|
+
f'<marker id="{marker_prefix}diamond" markerWidth="14" '
|
|
496
|
+
'markerHeight="8" refX="1" '
|
|
497
|
+
'refY="4" orient="auto" markerUnits="userSpaceOnUse">'
|
|
498
|
+
'<path d="M1,4 L7,0.5 L13,4 L7,7.5 z" fill="#ffffff" '
|
|
499
|
+
'stroke="#5b5b66"/></marker>',
|
|
500
|
+
'</defs>']
|
|
501
|
+
for edge in edges:
|
|
502
|
+
dotted = edge["dotted"] or edge["note_link"]
|
|
503
|
+
classes = "edge dotted" if dotted else "edge"
|
|
504
|
+
if edge["source_id"] in dim_ids or edge["target_id"] in dim_ids:
|
|
505
|
+
classes += " dim"
|
|
506
|
+
if edge["note_link"]:
|
|
507
|
+
markers = ""
|
|
508
|
+
elif edge["containment"]:
|
|
509
|
+
markers = f' marker-start="url(#{marker_prefix}diamond)"'
|
|
510
|
+
else:
|
|
511
|
+
markers = f' marker-end="url(#{marker_prefix}arrow)"'
|
|
512
|
+
points = " ".join(f"{x:.1f},{y:.1f}" for x, y in edge["points"])
|
|
513
|
+
svg.append(
|
|
514
|
+
f'<polyline class="{classes}" points="{points}" '
|
|
515
|
+
f'fill="none"{markers}>'
|
|
516
|
+
f'<title>{html.escape(edge["label"])}</title></polyline>')
|
|
517
|
+
svg.append("</svg>")
|
|
518
|
+
|
|
519
|
+
divs = []
|
|
520
|
+
for box in boxes:
|
|
521
|
+
style = (f'left:{box["x"]}px;top:{box["y"]}px;'
|
|
522
|
+
f'width:{box["w"]}px;height:{box["h"]}px')
|
|
523
|
+
dim = " dim" if box["id"] in dim_ids else ""
|
|
524
|
+
if box["kind"] == "element":
|
|
525
|
+
kind = "container" if box["container"] else "leaf"
|
|
526
|
+
name = html.escape(box["element"].get("name") or "")
|
|
527
|
+
description = html.escape(
|
|
528
|
+
model.properties(box["element"]).get("Omschrijving", ""))
|
|
529
|
+
title_attr = f' title="{description}"' if description else ""
|
|
530
|
+
divs.append(
|
|
531
|
+
f'<div class="box {kind} {layer_of(box["element"])}{dim}" '
|
|
532
|
+
f'style="{style}"{title_attr}>'
|
|
533
|
+
f'{element_icon(box["element"])}{name}</div>')
|
|
534
|
+
elif box["kind"] == "note":
|
|
535
|
+
content = box["node"].find("content")
|
|
536
|
+
text = html.escape(
|
|
537
|
+
(content.text if content is not None else "") or "")
|
|
538
|
+
divs.append(
|
|
539
|
+
f'<div class="box note{dim}" style="{style}">{text}</div>')
|
|
540
|
+
elif box["kind"] == "group":
|
|
541
|
+
name = html.escape(box["node"].get("name") or "")
|
|
542
|
+
divs.append(
|
|
543
|
+
f'<div class="box group{dim}" style="{style}">{name}</div>')
|
|
544
|
+
else: # reference to another view
|
|
545
|
+
ref = index.get(box["node"].get("model") or "")
|
|
546
|
+
ref_name = (ref.get("name") or "") if ref is not None else ""
|
|
547
|
+
href = ref_base + slugify(ref_name or "view") + ".html"
|
|
548
|
+
divs.append(
|
|
549
|
+
f'<div class="box ref{dim}" style="{style}">'
|
|
550
|
+
f'{VIEW_REF_ICON}<a href="{href}">'
|
|
551
|
+
f'{html.escape(ref_name or "(view)")}</a></div>')
|
|
552
|
+
|
|
553
|
+
return {"svg": "".join(svg), "divs": "".join(divs),
|
|
554
|
+
"width": width, "height": height,
|
|
555
|
+
"boxes": boxes, "edges": edges}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def render_view_html(model, diagram) -> str:
|
|
559
|
+
canvas = diagram_canvas(model, diagram)
|
|
560
|
+
name = diagram.get("name") or "(naamloze view)"
|
|
561
|
+
documentation = model.documentation(diagram)
|
|
562
|
+
doc_html = (f" <p class=\"doc\">{html.escape(documentation)}</p>\n"
|
|
563
|
+
if documentation else "")
|
|
564
|
+
body = (
|
|
565
|
+
f' <nldd-link href="index.html" size="sm" '
|
|
566
|
+
f'start-icon="arrow-left" text="Alle views"></nldd-link>\n'
|
|
567
|
+
f' <nldd-spacer size="8"></nldd-spacer>\n'
|
|
568
|
+
f' <nldd-title size="2"><h1>{html.escape(name)}</h1></nldd-title>\n'
|
|
569
|
+
f'{doc_html}'
|
|
570
|
+
f' <nldd-spacer size="16"></nldd-spacer>\n'
|
|
571
|
+
f' <div class="diagram-wrap">\n'
|
|
572
|
+
f' <div class="diagram" '
|
|
573
|
+
f'style="width:{canvas["width"]}px;height:{canvas["height"]}px">\n'
|
|
574
|
+
f' {canvas["svg"]}\n'
|
|
575
|
+
f' {canvas["divs"]}\n'
|
|
576
|
+
f' </div>\n'
|
|
577
|
+
f' {legend_html(canvas["boxes"], canvas["edges"])}\n'
|
|
578
|
+
f' </div>\n'
|
|
579
|
+
f' <nldd-spacer size="16"></nldd-spacer>\n'
|
|
580
|
+
f' <p class="meta">{len(canvas["boxes"])} elementen, '
|
|
581
|
+
f'{len(canvas["edges"])} '
|
|
582
|
+
f'getekende verbindingen · gegenereerd uit '
|
|
583
|
+
f'<code>{html.escape(display_model_path(model))}</code></p>')
|
|
584
|
+
return page_shell(name, body)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def render_index_html(model, entries) -> str:
|
|
588
|
+
cards = []
|
|
589
|
+
for name, filename, n_boxes, n_edges, thumbnail in entries:
|
|
590
|
+
cards.append(
|
|
591
|
+
f' <a class="card-link" href="{filename}">'
|
|
592
|
+
f'<nldd-card accessible-label="{html.escape(name)}">'
|
|
593
|
+
f'<nldd-container padding="16">'
|
|
594
|
+
f'<div class="thumb">{thumbnail}</div>'
|
|
595
|
+
f'<nldd-spacer size="12"></nldd-spacer>'
|
|
596
|
+
f'<nldd-title size="4"><h2>{html.escape(name)}</h2></nldd-title>'
|
|
597
|
+
f'<p class="meta">{n_boxes} elementen · {n_edges} verbindingen</p>'
|
|
598
|
+
f'</nldd-container>'
|
|
599
|
+
f'</nldd-card></a>')
|
|
600
|
+
body = (
|
|
601
|
+
f' <nldd-title size="2"><h1>{html.escape(model.name)}'
|
|
602
|
+
f'</h1></nldd-title>\n'
|
|
603
|
+
f' <p class="doc">Views gegenereerd uit '
|
|
604
|
+
f'<code>{html.escape(display_model_path(model))}</code>, '
|
|
605
|
+
f'met de layout zoals die in het model is vastgelegd.</p>\n'
|
|
606
|
+
f' <nldd-spacer size="16"></nldd-spacer>\n'
|
|
607
|
+
f' <nldd-collection layout="grid" item-width="320px">\n'
|
|
608
|
+
+ "\n".join(cards) + "\n"
|
|
609
|
+
f' </nldd-collection>')
|
|
610
|
+
return page_shell(f"{model.name} · views", body)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def render_all_html(model, out_dir) -> tuple[list, list]:
|
|
614
|
+
"""Render every view to HTML; returns (written, removed) path lists."""
|
|
615
|
+
out = Path(out_dir)
|
|
616
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
written, produced, entries = [], set(), []
|
|
618
|
+
|
|
619
|
+
index = model.id_index()
|
|
620
|
+
for diagram in model.diagrams():
|
|
621
|
+
name = diagram.get("name") or diagram.get("id")
|
|
622
|
+
filename = slugify(name) + ".html"
|
|
623
|
+
path = out / filename
|
|
624
|
+
if write_if_changed(path, render_view_html(model, diagram)):
|
|
625
|
+
written.append(path)
|
|
626
|
+
produced.add(path.name)
|
|
627
|
+
boxes = absolute_boxes(diagram, index)
|
|
628
|
+
edges = diagram_edges(model, diagram, {b["id"]: b for b in boxes})
|
|
629
|
+
entries.append((name, filename, len(boxes), len(edges),
|
|
630
|
+
view_thumbnail_svg(boxes)))
|
|
631
|
+
|
|
632
|
+
index_path = out / "index.html"
|
|
633
|
+
if write_if_changed(index_path, render_index_html(model, entries)):
|
|
634
|
+
written.append(index_path)
|
|
635
|
+
produced.add(index_path.name)
|
|
636
|
+
|
|
637
|
+
removed = []
|
|
638
|
+
for stale in out.glob("*.html"):
|
|
639
|
+
if stale.name in produced:
|
|
640
|
+
continue
|
|
641
|
+
first_line = stale.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
642
|
+
if first_line.strip() == MARKER:
|
|
643
|
+
stale.unlink()
|
|
644
|
+
removed.append(stale)
|
|
645
|
+
return written, removed
|