staticdash 2026.5__py3-none-any.whl → 2026.7__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.
- staticdash/assets/vendor/mermaid/mermaid.min.js +2029 -0
- staticdash/dashboard.py +565 -748
- staticdash-2026.7.dist-info/METADATA +98 -0
- {staticdash-2026.5.dist-info → staticdash-2026.7.dist-info}/RECORD +6 -5
- {staticdash-2026.5.dist-info → staticdash-2026.7.dist-info}/WHEEL +1 -1
- staticdash-2026.5.dist-info/METADATA +0 -71
- {staticdash-2026.5.dist-info → staticdash-2026.7.dist-info}/top_level.txt +0 -0
staticdash/dashboard.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
|
2
2
|
import shutil
|
|
3
3
|
import uuid
|
|
4
4
|
import re
|
|
5
|
+
from typing import Optional, Tuple, List, Any
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import plotly.graph_objects as go
|
|
7
8
|
from dominate import document
|
|
@@ -12,6 +13,63 @@ import io
|
|
|
12
13
|
import base64
|
|
13
14
|
import matplotlib
|
|
14
15
|
from matplotlib import rc_context
|
|
16
|
+
import json
|
|
17
|
+
import markdown
|
|
18
|
+
|
|
19
|
+
# Constants
|
|
20
|
+
DEFAULT_PAGE_WIDTH = 900
|
|
21
|
+
DEFAULT_SIDEBAR_WIDTH = 240
|
|
22
|
+
DEFAULT_CONTENT_PADDING = 20
|
|
23
|
+
HEADER_TAGS = {1: h1, 2: h2, 3: h3, 4: h4}
|
|
24
|
+
VALID_ALIGNMENTS = {"left", "center", "right"}
|
|
25
|
+
DEFAULT_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_markdown_text(text):
|
|
29
|
+
"""
|
|
30
|
+
Render markdown text to HTML, preserving math expressions.
|
|
31
|
+
|
|
32
|
+
Math expressions in $...$ (inline) or $$...$$ (display) format are
|
|
33
|
+
protected during markdown processing and passed through for MathJax.
|
|
34
|
+
"""
|
|
35
|
+
math_blocks = []
|
|
36
|
+
|
|
37
|
+
def replace_math(match):
|
|
38
|
+
math_blocks.append(match.group(0))
|
|
39
|
+
# Use a placeholder that won't be affected by markdown processing
|
|
40
|
+
return f"STATICDASHMATH{len(math_blocks)-1}ENDMATH"
|
|
41
|
+
|
|
42
|
+
# Protect math expressions from markdown processing
|
|
43
|
+
text = re.sub(r'\$\$([^$]+)\$\$', replace_math, text, flags=re.DOTALL)
|
|
44
|
+
text = re.sub(r'\$([^$]+)\$', replace_math, text, flags=re.DOTALL)
|
|
45
|
+
|
|
46
|
+
# Process markdown with extended features
|
|
47
|
+
md = markdown.Markdown(extensions=[
|
|
48
|
+
'extra', # Tables, fenced code blocks, etc.
|
|
49
|
+
'nl2br', # Convert newlines to <br>
|
|
50
|
+
'sane_lists', # Better list handling
|
|
51
|
+
'pymdownx.tilde', # Strikethrough with ~~text~~
|
|
52
|
+
'pymdownx.superfences', # Better code blocks with custom fence support
|
|
53
|
+
], extension_configs={
|
|
54
|
+
'pymdownx.superfences': {
|
|
55
|
+
'custom_fences': [
|
|
56
|
+
{
|
|
57
|
+
'name': 'mermaid',
|
|
58
|
+
'class': 'mermaid',
|
|
59
|
+
'format': lambda source, language, css_class, options, md, **kwargs:
|
|
60
|
+
f'<div class="mermaid">\n{source}\n</div>'
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
html_content = md.convert(text)
|
|
66
|
+
|
|
67
|
+
# Restore math expressions
|
|
68
|
+
for i, block in enumerate(math_blocks):
|
|
69
|
+
html_content = html_content.replace(f"STATICDASHMATH{i}ENDMATH", block)
|
|
70
|
+
|
|
71
|
+
return html_content
|
|
72
|
+
|
|
15
73
|
|
|
16
74
|
def split_paragraphs_preserving_math(text):
|
|
17
75
|
"""
|
|
@@ -20,7 +78,6 @@ def split_paragraphs_preserving_math(text):
|
|
|
20
78
|
"""
|
|
21
79
|
math_blocks = []
|
|
22
80
|
|
|
23
|
-
# Replace math with placeholders
|
|
24
81
|
def replace_math(match):
|
|
25
82
|
math_blocks.append(match.group(0))
|
|
26
83
|
return f"__MATH_BLOCK_{len(math_blocks)-1}__"
|
|
@@ -29,18 +86,171 @@ def split_paragraphs_preserving_math(text):
|
|
|
29
86
|
text = re.sub(r'\$\$([^$]+)\$\$', replace_math, text, flags=re.DOTALL)
|
|
30
87
|
text = re.sub(r'\$([^$]+)\$', replace_math, text, flags=re.DOTALL)
|
|
31
88
|
|
|
32
|
-
# Split on double newlines
|
|
33
|
-
paragraphs =
|
|
34
|
-
|
|
35
|
-
# Restore math in each paragraph
|
|
36
|
-
restored_paragraphs = []
|
|
37
|
-
for para in paragraphs:
|
|
89
|
+
# Split on double newlines and restore math
|
|
90
|
+
paragraphs = []
|
|
91
|
+
for para in text.split('\n\n'):
|
|
38
92
|
for i, block in enumerate(math_blocks):
|
|
39
93
|
para = para.replace(f"__MATH_BLOCK_{i}__", block)
|
|
40
|
-
|
|
94
|
+
if para.strip():
|
|
95
|
+
paragraphs.append(para.strip())
|
|
96
|
+
|
|
97
|
+
return paragraphs
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_alignment_style(align: str) -> str:
|
|
101
|
+
"""Generate CSS flexbox alignment style."""
|
|
102
|
+
align = align if align in VALID_ALIGNMENTS else "center"
|
|
103
|
+
justify_map = {"center": "center", "left": "flex-start", "right": "flex-end"}
|
|
104
|
+
return f"display:flex; justify-content:{justify_map[align]}; align-items:center;"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_plot_params(content: Any) -> Tuple[Any, Optional[int], Optional[int], str]:
|
|
108
|
+
"""Extract figure, height, width, and alignment from plot content tuple."""
|
|
109
|
+
if not isinstance(content, (list, tuple)):
|
|
110
|
+
return content, None, None, "center"
|
|
111
|
+
|
|
112
|
+
if len(content) == 4:
|
|
113
|
+
return content
|
|
114
|
+
elif len(content) == 3:
|
|
115
|
+
return (*content, "center")
|
|
116
|
+
elif len(content) == 2:
|
|
117
|
+
return (*content, None, "center")
|
|
118
|
+
else:
|
|
119
|
+
return content[0], None, None, "center"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_figure_layout_attr(fig, attr: str, default=None):
|
|
123
|
+
"""Safely get a layout attribute from a figure."""
|
|
124
|
+
try:
|
|
125
|
+
return getattr(getattr(fig, 'layout', None), attr, default)
|
|
126
|
+
except Exception:
|
|
127
|
+
return default
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def set_figure_dimensions(fig, height: Optional[int], width: Optional[int]) -> Tuple[Optional[int], Optional[int]]:
|
|
131
|
+
"""Set figure dimensions and return original values."""
|
|
132
|
+
orig_height = get_figure_layout_attr(fig, 'height')
|
|
133
|
+
orig_width = get_figure_layout_attr(fig, 'width')
|
|
41
134
|
|
|
42
|
-
|
|
43
|
-
|
|
135
|
+
try:
|
|
136
|
+
if height is not None:
|
|
137
|
+
fig.update_layout(height=height)
|
|
138
|
+
if width is not None:
|
|
139
|
+
fig.update_layout(width=width)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
return orig_height, orig_width
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def restore_figure_dimensions(fig, orig_height: Optional[int], orig_width: Optional[int]):
|
|
147
|
+
"""Restore original figure dimensions."""
|
|
148
|
+
try:
|
|
149
|
+
if orig_height is not None or orig_width is not None:
|
|
150
|
+
fig.update_layout(height=orig_height, width=orig_width)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ensure_figure_font(fig):
|
|
156
|
+
"""Ensure figure has a robust font family."""
|
|
157
|
+
try:
|
|
158
|
+
font_family = get_figure_layout_attr(fig, 'font.family')
|
|
159
|
+
if not font_family:
|
|
160
|
+
fig.update_layout(font_family=DEFAULT_FONT_FAMILY)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_container_style(width: Optional[int] = None, height: Optional[int] = None) -> str:
|
|
166
|
+
"""Build container style string for plots."""
|
|
167
|
+
style = "width:100%;" if width is None else f"width:{width}px;"
|
|
168
|
+
if height is not None:
|
|
169
|
+
style += f" height:{height}px;"
|
|
170
|
+
return style
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_plotly_figure(fig, specified_height: Optional[int], specified_width: Optional[int], specified_align: str) -> str:
|
|
174
|
+
"""Render a Plotly figure to HTML with deferred loading."""
|
|
175
|
+
ensure_figure_font(fig)
|
|
176
|
+
orig_height, orig_width = set_figure_dimensions(fig, specified_height, specified_width)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
fig_json = fig.to_json()
|
|
180
|
+
fig_json = fig_json.replace('</script>', '<\\/script>')
|
|
181
|
+
div_id = f'plot-{uuid.uuid4()}'
|
|
182
|
+
container_style = build_container_style(specified_width, specified_height)
|
|
183
|
+
|
|
184
|
+
plot_div = f'<div id="{div_id}" class="plotly-graph-div" style="{container_style}"></div>'
|
|
185
|
+
loader = (
|
|
186
|
+
'<script type="text/javascript">(function(){'
|
|
187
|
+
f'var fig = {fig_json};'
|
|
188
|
+
'function tryPlot(){'
|
|
189
|
+
'if(window.Plotly && typeof window.Plotly.newPlot === "function"){'
|
|
190
|
+
f'Plotly.newPlot("{div_id}", fig.data, fig.layout, {json.dumps({"responsive": True})});'
|
|
191
|
+
'} else { setTimeout(tryPlot, 50); }'
|
|
192
|
+
'}'
|
|
193
|
+
'if(document.readyState === "complete"){ tryPlot(); } else { window.addEventListener("load", tryPlot); }'
|
|
194
|
+
'})();</script>'
|
|
195
|
+
)
|
|
196
|
+
plot_wrapped = plot_div + loader
|
|
197
|
+
except Exception:
|
|
198
|
+
# Fallback to older method
|
|
199
|
+
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
200
|
+
container_style = build_container_style(specified_width, specified_height)
|
|
201
|
+
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
202
|
+
finally:
|
|
203
|
+
restore_figure_dimensions(fig, orig_height, orig_width)
|
|
204
|
+
|
|
205
|
+
align_style = get_alignment_style(specified_align)
|
|
206
|
+
return f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_matplotlib_figure(fig, specified_height: Optional[int], specified_width: Optional[int], specified_align: str) -> str:
|
|
210
|
+
"""Render a Matplotlib figure to base64 PNG."""
|
|
211
|
+
buf = io.BytesIO()
|
|
212
|
+
try:
|
|
213
|
+
# Adjust figure size if dimensions specified
|
|
214
|
+
orig_size = None
|
|
215
|
+
try:
|
|
216
|
+
dpi = fig.get_dpi()
|
|
217
|
+
if dpi and (specified_height or specified_width):
|
|
218
|
+
orig_size = fig.get_size_inches()
|
|
219
|
+
new_w, new_h = orig_size
|
|
220
|
+
if specified_width:
|
|
221
|
+
new_w = specified_width / dpi
|
|
222
|
+
if specified_height:
|
|
223
|
+
new_h = specified_height / dpi
|
|
224
|
+
fig.set_size_inches(new_w, new_h)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
with rc_context({"axes.unicode_minus": False}):
|
|
229
|
+
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
230
|
+
|
|
231
|
+
# Restore original size
|
|
232
|
+
if orig_size is not None:
|
|
233
|
+
try:
|
|
234
|
+
fig.set_size_inches(orig_size)
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
buf.seek(0)
|
|
239
|
+
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
240
|
+
|
|
241
|
+
img_style = "max-width:100%;"
|
|
242
|
+
if specified_height:
|
|
243
|
+
img_style += f" height:{specified_height}px;"
|
|
244
|
+
if specified_width:
|
|
245
|
+
img_style += f" width:{specified_width}px;"
|
|
246
|
+
|
|
247
|
+
align_style = get_alignment_style(specified_align)
|
|
248
|
+
return f'<div style="{align_style}"><img src="data:image/png;base64,{img_base64}" style="{img_style}"></div>'
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return f'<div>Matplotlib figure could not be rendered: {e}</div>'
|
|
251
|
+
finally:
|
|
252
|
+
buf.close()
|
|
253
|
+
|
|
44
254
|
|
|
45
255
|
class AbstractPage:
|
|
46
256
|
def __init__(self):
|
|
@@ -55,14 +265,7 @@ class AbstractPage:
|
|
|
55
265
|
self.elements.append(("text", text, width))
|
|
56
266
|
|
|
57
267
|
def add_plot(self, plot, el_width=None, height=None, width=None, align="center"):
|
|
58
|
-
|
|
59
|
-
# `height` and `width` are pixel dimensions for the rendered figure/image.
|
|
60
|
-
# `align` controls horizontal alignment: 'left', 'center' (default), or 'right'.
|
|
61
|
-
# We store a tuple (plot, height, width, align) and keep `el_width` as
|
|
62
|
-
# the element-level fractional width for compatibility with page layout.
|
|
63
|
-
specified_height = height
|
|
64
|
-
specified_width = width
|
|
65
|
-
self.elements.append(("plot", (plot, specified_height, specified_width, align), el_width))
|
|
268
|
+
self.elements.append(("plot", (plot, height, width, align), el_width))
|
|
66
269
|
|
|
67
270
|
def add_table(self, df, table_id=None, sortable=True, width=None):
|
|
68
271
|
self.elements.append(("table", df, width))
|
|
@@ -78,6 +281,68 @@ class AbstractPage:
|
|
|
78
281
|
def add_syntax(self, code, language="python", width=None):
|
|
79
282
|
self.elements.append(("syntax", (code, language), width))
|
|
80
283
|
|
|
284
|
+
def _render_element(self, kind, content, index=None, downloads_dir=None,
|
|
285
|
+
relative_prefix="", inherited_width=None,
|
|
286
|
+
inherited_marking=None, inherited_distribution=None):
|
|
287
|
+
"""Common element rendering logic shared by Page and MiniPage."""
|
|
288
|
+
if kind == "text":
|
|
289
|
+
# Render markdown to HTML
|
|
290
|
+
html_content = render_markdown_text(content)
|
|
291
|
+
return div(raw_util(html_content))
|
|
292
|
+
|
|
293
|
+
elif kind == "header":
|
|
294
|
+
text, level = content
|
|
295
|
+
return HEADER_TAGS[level](text)
|
|
296
|
+
|
|
297
|
+
elif kind == "plot":
|
|
298
|
+
fig, specified_height, specified_width, specified_align = extract_plot_params(content)
|
|
299
|
+
|
|
300
|
+
if hasattr(fig, "to_html"):
|
|
301
|
+
html_content = render_plotly_figure(fig, specified_height, specified_width, specified_align)
|
|
302
|
+
else:
|
|
303
|
+
html_content = render_matplotlib_figure(fig, specified_height, specified_width, specified_align)
|
|
304
|
+
|
|
305
|
+
return div(raw_util(html_content))
|
|
306
|
+
|
|
307
|
+
elif kind == "table":
|
|
308
|
+
df = content
|
|
309
|
+
try:
|
|
310
|
+
html_table = df.to_html(
|
|
311
|
+
classes="table-hover table-striped",
|
|
312
|
+
index=False,
|
|
313
|
+
border=0,
|
|
314
|
+
table_id=f"table-{index}" if index else None,
|
|
315
|
+
escape=False
|
|
316
|
+
)
|
|
317
|
+
return div(raw_util(html_table))
|
|
318
|
+
except Exception as e:
|
|
319
|
+
return div(f"Table could not be rendered: {e}")
|
|
320
|
+
|
|
321
|
+
elif kind == "download":
|
|
322
|
+
file_path, label = content
|
|
323
|
+
btn = a(label or os.path.basename(file_path), href=file_path,
|
|
324
|
+
cls="download-button", download=True)
|
|
325
|
+
return div(btn)
|
|
326
|
+
|
|
327
|
+
elif kind == "syntax":
|
|
328
|
+
code, language = content
|
|
329
|
+
return div(
|
|
330
|
+
raw_util(f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'),
|
|
331
|
+
cls="syntax-block"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
elif kind == "minipage":
|
|
335
|
+
return content.render(
|
|
336
|
+
index,
|
|
337
|
+
downloads_dir=downloads_dir,
|
|
338
|
+
relative_prefix=relative_prefix,
|
|
339
|
+
inherited_width=inherited_width,
|
|
340
|
+
inherited_marking=inherited_marking,
|
|
341
|
+
inherited_distribution=inherited_distribution
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return div("Unknown element type")
|
|
345
|
+
|
|
81
346
|
|
|
82
347
|
class Page(AbstractPage):
|
|
83
348
|
def __init__(self, slug, title, page_width=None, marking=None):
|
|
@@ -91,266 +356,60 @@ class Page(AbstractPage):
|
|
|
91
356
|
def add_subpage(self, page):
|
|
92
357
|
self.children.append(page)
|
|
93
358
|
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
elements = [
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"z-index: 1000; "
|
|
122
|
-
"font-weight: normal;"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
elements.append(div(
|
|
126
|
-
effective_marking,
|
|
127
|
-
cls="floating-header",
|
|
128
|
-
style=f"{shared_pos} top: 0;"
|
|
129
|
-
))
|
|
359
|
+
def _render_marking_bars(self, effective_marking, effective_distribution):
|
|
360
|
+
"""Render top and bottom marking bars."""
|
|
361
|
+
if not effective_marking:
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
shared_pos = (
|
|
365
|
+
"position: fixed; "
|
|
366
|
+
f"left: calc(var(--sidebar-width, {DEFAULT_SIDEBAR_WIDTH}px) + var(--content-padding-x, {DEFAULT_CONTENT_PADDING}px)); "
|
|
367
|
+
f"width: calc(100vw - var(--sidebar-width, {DEFAULT_SIDEBAR_WIDTH}px) - 2*var(--content-padding-x, {DEFAULT_CONTENT_PADDING}px)); "
|
|
368
|
+
"text-align: center; background-color: #f8f9fa; padding: 10px; "
|
|
369
|
+
"z-index: 1000; font-weight: normal;"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
elements = [
|
|
373
|
+
div(effective_marking, cls="floating-header", style=f"{shared_pos} top: 0;")
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
footer_content = []
|
|
377
|
+
if effective_distribution:
|
|
378
|
+
footer_content.append(div(effective_distribution, style="margin-bottom: 4px; font-size: 10pt;"))
|
|
379
|
+
footer_content.append(div(effective_marking))
|
|
380
|
+
|
|
381
|
+
elements.append(
|
|
382
|
+
div(*footer_content, cls="floating-footer", style=f"{shared_pos} bottom: 0;")
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return elements
|
|
130
386
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
387
|
+
def render(self, index, downloads_dir=None, relative_prefix="",
|
|
388
|
+
inherited_width=None, inherited_marking=None, inherited_distribution=None):
|
|
389
|
+
effective_width = self.page_width or inherited_width or DEFAULT_PAGE_WIDTH
|
|
390
|
+
effective_marking = self.marking if self.marking is not None else inherited_marking
|
|
391
|
+
effective_distribution = getattr(self, "distribution", None) or inherited_distribution
|
|
135
392
|
|
|
136
|
-
|
|
137
|
-
*footer_block,
|
|
138
|
-
cls="floating-footer",
|
|
139
|
-
style=f"{shared_pos} bottom: 0;"
|
|
140
|
-
))
|
|
393
|
+
elements = self._render_marking_bars(effective_marking, effective_distribution)
|
|
141
394
|
|
|
142
|
-
# Render elements
|
|
395
|
+
# Render all page elements
|
|
143
396
|
for kind, content, el_width in self.elements:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
elem = None
|
|
150
|
-
if kind == "text":
|
|
151
|
-
paragraphs = split_paragraphs_preserving_math(content)
|
|
152
|
-
if paragraphs:
|
|
153
|
-
elem = div(*[p(para) for para in paragraphs])
|
|
154
|
-
else:
|
|
155
|
-
elem = p(content)
|
|
156
|
-
elif kind == "header":
|
|
157
|
-
text, level = content
|
|
158
|
-
header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
|
|
159
|
-
elem = header_tag(text)
|
|
160
|
-
elif kind == "plot":
|
|
161
|
-
# content may be stored as (figure, height), (figure, height, width)
|
|
162
|
-
# or (figure, height, width, align)
|
|
163
|
-
specified_height = None
|
|
164
|
-
specified_width = None
|
|
165
|
-
specified_align = "center"
|
|
166
|
-
if isinstance(content, (list, tuple)):
|
|
167
|
-
if len(content) == 4:
|
|
168
|
-
fig, specified_height, specified_width, specified_align = content
|
|
169
|
-
elif len(content) == 3:
|
|
170
|
-
fig, specified_height, specified_width = content
|
|
171
|
-
elif len(content) == 2:
|
|
172
|
-
fig, specified_height = content
|
|
173
|
-
else:
|
|
174
|
-
fig = content
|
|
175
|
-
else:
|
|
176
|
-
fig = content
|
|
177
|
-
|
|
178
|
-
if hasattr(fig, "to_html"):
|
|
179
|
-
# Use local Plotly loaded in <head>
|
|
180
|
-
# Ensure the figure uses a robust font family so minus signs and other
|
|
181
|
-
# glyphs render correctly in the browser (some system fonts lack U+2212)
|
|
182
|
-
try:
|
|
183
|
-
font_family = None
|
|
184
|
-
if getattr(fig, 'layout', None) and getattr(fig.layout, 'font', None):
|
|
185
|
-
font_family = getattr(fig.layout.font, 'family', None)
|
|
186
|
-
if not font_family:
|
|
187
|
-
fig.update_layout(font_family='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
|
|
188
|
-
except Exception:
|
|
189
|
-
# Be defensive: don't fail rendering if layout manipulation isn't available
|
|
190
|
-
pass
|
|
191
|
-
|
|
192
|
-
try:
|
|
193
|
-
# Temporarily set layout height/width if specified (pixels)
|
|
194
|
-
orig_height = None
|
|
195
|
-
orig_width = None
|
|
196
|
-
try:
|
|
197
|
-
orig_height = getattr(fig.layout, 'height', None)
|
|
198
|
-
except Exception:
|
|
199
|
-
orig_height = None
|
|
200
|
-
try:
|
|
201
|
-
orig_width = getattr(fig.layout, 'width', None)
|
|
202
|
-
except Exception:
|
|
203
|
-
orig_width = None
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
if specified_height is not None:
|
|
207
|
-
fig.update_layout(height=specified_height)
|
|
208
|
-
if specified_width is not None:
|
|
209
|
-
fig.update_layout(width=specified_width)
|
|
210
|
-
except Exception:
|
|
211
|
-
pass
|
|
212
|
-
|
|
213
|
-
# If a local vendored Plotly exists, rely on the head script.
|
|
214
|
-
# Otherwise include Plotly from CDN so the inline newPlot call works.
|
|
215
|
-
# Always rely on the page-level Plotly include (head). Avoid
|
|
216
|
-
# embedding another Plotly bundle inside each fragment which
|
|
217
|
-
# can lead to multiple conflicting versions in the same page.
|
|
218
|
-
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
219
|
-
|
|
220
|
-
# Wrap the Plotly HTML in a container with explicit pixel sizing
|
|
221
|
-
container_style = "width:100%;"
|
|
222
|
-
if specified_width is not None:
|
|
223
|
-
container_style = f"width:{specified_width}px;"
|
|
224
|
-
if specified_height is not None:
|
|
225
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
226
|
-
|
|
227
|
-
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
228
|
-
# Apply alignment wrapper
|
|
229
|
-
if specified_align not in ("left", "right", "center"):
|
|
230
|
-
specified_align = "center"
|
|
231
|
-
if specified_align == "center":
|
|
232
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
233
|
-
elif specified_align == "left":
|
|
234
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
235
|
-
else:
|
|
236
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
237
|
-
|
|
238
|
-
outer = f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
239
|
-
elem = div(raw_util(outer))
|
|
240
|
-
|
|
241
|
-
# restore original height/width if we changed them
|
|
242
|
-
try:
|
|
243
|
-
if specified_height is not None:
|
|
244
|
-
fig.update_layout(height=orig_height)
|
|
245
|
-
if specified_width is not None:
|
|
246
|
-
fig.update_layout(width=orig_width)
|
|
247
|
-
except Exception:
|
|
248
|
-
pass
|
|
249
|
-
except Exception as e:
|
|
250
|
-
elem = div(f"Plotly figure could not be rendered: {e}")
|
|
251
|
-
else:
|
|
252
|
-
# Robust Matplotlib -> PNG path. Ensure `buf` exists and is closed.
|
|
253
|
-
buf = io.BytesIO()
|
|
254
|
-
try:
|
|
255
|
-
# If pixel width/height specified, attempt to adjust figure size
|
|
256
|
-
orig_size = None
|
|
257
|
-
try:
|
|
258
|
-
dpi = fig.get_dpi()
|
|
259
|
-
except Exception:
|
|
260
|
-
dpi = None
|
|
261
|
-
try:
|
|
262
|
-
if dpi is not None and (specified_height is not None or specified_width is not None):
|
|
263
|
-
orig_size = fig.get_size_inches()
|
|
264
|
-
new_w = orig_size[0]
|
|
265
|
-
new_h = orig_size[1]
|
|
266
|
-
if specified_width is not None:
|
|
267
|
-
new_w = specified_width / dpi
|
|
268
|
-
if specified_height is not None:
|
|
269
|
-
new_h = specified_height / dpi
|
|
270
|
-
fig.set_size_inches(new_w, new_h)
|
|
271
|
-
except Exception:
|
|
272
|
-
orig_size = None
|
|
273
|
-
|
|
274
|
-
with rc_context({"axes.unicode_minus": False}):
|
|
275
|
-
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
276
|
-
|
|
277
|
-
# restore original size if changed
|
|
278
|
-
try:
|
|
279
|
-
if orig_size is not None:
|
|
280
|
-
fig.set_size_inches(orig_size)
|
|
281
|
-
except Exception:
|
|
282
|
-
pass
|
|
283
|
-
|
|
284
|
-
buf.seek(0)
|
|
285
|
-
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
286
|
-
img_style = "max-width:100%;"
|
|
287
|
-
if specified_height is not None:
|
|
288
|
-
img_style = img_style + f" height:{specified_height}px;"
|
|
289
|
-
if specified_width is not None:
|
|
290
|
-
img_style = img_style + f" width:{specified_width}px;"
|
|
291
|
-
|
|
292
|
-
if specified_align not in ("left", "right", "center"):
|
|
293
|
-
specified_align = "center"
|
|
294
|
-
if specified_align == "center":
|
|
295
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
296
|
-
elif specified_align == "left":
|
|
297
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
298
|
-
else:
|
|
299
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
300
|
-
|
|
301
|
-
elem = div(
|
|
302
|
-
raw_util(f'<img src="data:image/png;base64,{img_base64}" style="{img_style}">'),
|
|
303
|
-
style=align_style
|
|
304
|
-
)
|
|
305
|
-
except Exception as e:
|
|
306
|
-
elem = div(f"Matplotlib figure could not be rendered: {e}")
|
|
307
|
-
finally:
|
|
308
|
-
try:
|
|
309
|
-
buf.close()
|
|
310
|
-
except Exception:
|
|
311
|
-
pass
|
|
312
|
-
elif kind == "table":
|
|
313
|
-
df = content
|
|
314
|
-
try:
|
|
315
|
-
html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
|
|
316
|
-
elem = div(raw_util(html_table))
|
|
317
|
-
except Exception as e:
|
|
318
|
-
elem = div(f"Table could not be rendered: {e}")
|
|
319
|
-
elif kind == "download":
|
|
320
|
-
file_path, label = content
|
|
321
|
-
btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
|
|
322
|
-
elem = div(btn)
|
|
323
|
-
elif kind == "syntax":
|
|
324
|
-
code, language = content
|
|
325
|
-
elem = div(
|
|
326
|
-
raw_util(
|
|
327
|
-
f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
|
|
328
|
-
),
|
|
329
|
-
cls="syntax-block"
|
|
330
|
-
)
|
|
331
|
-
elif kind == "minipage":
|
|
332
|
-
elem = content.render(
|
|
333
|
-
index,
|
|
334
|
-
downloads_dir=downloads_dir,
|
|
335
|
-
relative_prefix=relative_prefix,
|
|
336
|
-
inherited_width=effective_width,
|
|
337
|
-
inherited_marking=effective_marking,
|
|
338
|
-
inherited_distribution=effective_distribution
|
|
339
|
-
)
|
|
397
|
+
elem = self._render_element(
|
|
398
|
+
kind, content, index, downloads_dir, relative_prefix,
|
|
399
|
+
effective_width, effective_marking, effective_distribution
|
|
400
|
+
)
|
|
401
|
+
|
|
340
402
|
if el_width is not None:
|
|
341
|
-
elem = div(elem, style=
|
|
342
|
-
elem = div(elem, style=
|
|
403
|
+
elem = div(elem, style=f"width: {el_width * 100}%;")
|
|
404
|
+
elem = div(elem, style="display: flex; justify-content: center; margin: 0 auto;")
|
|
405
|
+
|
|
343
406
|
elements.append(elem)
|
|
344
407
|
|
|
345
|
-
# Expose --content-width if you need it later, but centering no longer depends on it
|
|
346
408
|
wrapper_style = (
|
|
347
|
-
f"max-width: {effective_width}px; "
|
|
348
|
-
"
|
|
349
|
-
"padding-top: 80px; padding-bottom: 80px; "
|
|
350
|
-
f"--content-width: {effective_width}px;"
|
|
409
|
+
f"max-width: {effective_width}px; margin: 0 auto; width: 100%; "
|
|
410
|
+
f"padding-top: 80px; padding-bottom: 80px; --content-width: {effective_width}px;"
|
|
351
411
|
)
|
|
352
|
-
|
|
353
|
-
return [wrapper]
|
|
412
|
+
return [div(*elements, style=wrapper_style)]
|
|
354
413
|
|
|
355
414
|
|
|
356
415
|
class MiniPage(AbstractPage):
|
|
@@ -358,199 +417,31 @@ class MiniPage(AbstractPage):
|
|
|
358
417
|
super().__init__()
|
|
359
418
|
self.page_width = page_width
|
|
360
419
|
|
|
361
|
-
def render(self, index=None, downloads_dir=None, relative_prefix="",
|
|
362
|
-
|
|
363
|
-
|
|
420
|
+
def render(self, index=None, downloads_dir=None, relative_prefix="",
|
|
421
|
+
inherited_width=None, inherited_marking=None, inherited_distribution=None):
|
|
422
|
+
effective_width = self.page_width or inherited_width or DEFAULT_PAGE_WIDTH
|
|
423
|
+
row_div = div(
|
|
424
|
+
cls="minipage-row",
|
|
425
|
+
style=f"max-width: {effective_width}px; margin: 0 auto; width: 100%;"
|
|
426
|
+
)
|
|
427
|
+
|
|
364
428
|
for kind, content, el_width in self.elements:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
elem = None
|
|
371
|
-
if kind == "text":
|
|
372
|
-
paragraphs = split_paragraphs_preserving_math(content)
|
|
373
|
-
if paragraphs:
|
|
374
|
-
elem = div(*[p(para) for para in paragraphs])
|
|
375
|
-
else:
|
|
376
|
-
elem = p(content)
|
|
377
|
-
elif kind == "header":
|
|
378
|
-
text, level = content
|
|
379
|
-
header_tag = {1: h1, 2: h2, 3: h3, 4: h4}[level]
|
|
380
|
-
elem = header_tag(text)
|
|
381
|
-
elif kind == "plot":
|
|
382
|
-
# content may be stored as (figure, height, width, align)
|
|
383
|
-
specified_height = None
|
|
384
|
-
specified_width = None
|
|
385
|
-
specified_align = "center"
|
|
386
|
-
if isinstance(content, (list, tuple)):
|
|
387
|
-
if len(content) == 4:
|
|
388
|
-
fig, specified_height, specified_width, specified_align = content
|
|
389
|
-
elif len(content) == 3:
|
|
390
|
-
fig, specified_height, specified_width = content
|
|
391
|
-
elif len(content) == 2:
|
|
392
|
-
fig, specified_height = content
|
|
393
|
-
else:
|
|
394
|
-
fig = content
|
|
395
|
-
else:
|
|
396
|
-
fig = content
|
|
397
|
-
|
|
398
|
-
if hasattr(fig, "to_html"):
|
|
399
|
-
# Use local Plotly loaded in <head>
|
|
400
|
-
# Ensure the figure uses a robust font family so minus signs and other
|
|
401
|
-
# glyphs render correctly in the browser (some system fonts lack U+2212)
|
|
402
|
-
try:
|
|
403
|
-
font_family = None
|
|
404
|
-
if getattr(fig, 'layout', None) and getattr(fig.layout, 'font', None):
|
|
405
|
-
font_family = getattr(fig.layout.font, 'family', None)
|
|
406
|
-
if not font_family:
|
|
407
|
-
fig.update_layout(font_family='-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
|
|
408
|
-
except Exception:
|
|
409
|
-
pass
|
|
410
|
-
try:
|
|
411
|
-
orig_height = None
|
|
412
|
-
orig_width = None
|
|
413
|
-
try:
|
|
414
|
-
orig_height = getattr(fig.layout, 'height', None)
|
|
415
|
-
except Exception:
|
|
416
|
-
orig_height = None
|
|
417
|
-
try:
|
|
418
|
-
orig_width = getattr(fig.layout, 'width', None)
|
|
419
|
-
except Exception:
|
|
420
|
-
orig_width = None
|
|
421
|
-
|
|
422
|
-
try:
|
|
423
|
-
if specified_height is not None:
|
|
424
|
-
fig.update_layout(height=specified_height)
|
|
425
|
-
if specified_width is not None:
|
|
426
|
-
fig.update_layout(width=specified_width)
|
|
427
|
-
except Exception:
|
|
428
|
-
pass
|
|
429
|
-
|
|
430
|
-
# Always rely on the page-level Plotly include (head). Avoid
|
|
431
|
-
# embedding another Plotly bundle inside each fragment which
|
|
432
|
-
# can lead to multiple conflicting versions in the same page.
|
|
433
|
-
plotly_html = fig.to_html(full_html=False, include_plotlyjs=False, config={'responsive': True})
|
|
434
|
-
container_style = "width:100%;"
|
|
435
|
-
if specified_width is not None:
|
|
436
|
-
container_style = f"width:{specified_width}px;"
|
|
437
|
-
if specified_height is not None:
|
|
438
|
-
container_style = container_style + f" height:{specified_height}px;"
|
|
439
|
-
plot_wrapped = f'<div style="{container_style}">{plotly_html}</div>'
|
|
440
|
-
if specified_align not in ("left", "right", "center"):
|
|
441
|
-
specified_align = "center"
|
|
442
|
-
if specified_align == "center":
|
|
443
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
444
|
-
elif specified_align == "left":
|
|
445
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
446
|
-
else:
|
|
447
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
448
|
-
outer = f'<div style="{align_style}">{plot_wrapped}</div>'
|
|
449
|
-
elem = div(raw_util(outer))
|
|
450
|
-
|
|
451
|
-
try:
|
|
452
|
-
if specified_height is not None:
|
|
453
|
-
fig.update_layout(height=orig_height)
|
|
454
|
-
if specified_width is not None:
|
|
455
|
-
fig.update_layout(width=orig_width)
|
|
456
|
-
except Exception:
|
|
457
|
-
pass
|
|
458
|
-
except Exception as e:
|
|
459
|
-
elem = div(f"Plotly figure could not be rendered: {e}")
|
|
460
|
-
else:
|
|
461
|
-
# Robust Matplotlib -> PNG path. Ensure `buf` exists and is closed.
|
|
462
|
-
buf = io.BytesIO()
|
|
463
|
-
try:
|
|
464
|
-
# If pixel width/height specified, attempt to adjust figure size
|
|
465
|
-
orig_size = None
|
|
466
|
-
try:
|
|
467
|
-
dpi = fig.get_dpi()
|
|
468
|
-
except Exception:
|
|
469
|
-
dpi = None
|
|
470
|
-
try:
|
|
471
|
-
if dpi is not None and (specified_height is not None or specified_width is not None):
|
|
472
|
-
orig_size = fig.get_size_inches()
|
|
473
|
-
new_w = orig_size[0]
|
|
474
|
-
new_h = orig_size[1]
|
|
475
|
-
if specified_width is not None:
|
|
476
|
-
new_w = specified_width / dpi
|
|
477
|
-
if specified_height is not None:
|
|
478
|
-
new_h = specified_height / dpi
|
|
479
|
-
fig.set_size_inches(new_w, new_h)
|
|
480
|
-
except Exception:
|
|
481
|
-
orig_size = None
|
|
482
|
-
|
|
483
|
-
with rc_context({"axes.unicode_minus": False}):
|
|
484
|
-
fig.savefig(buf, format="png", bbox_inches="tight")
|
|
485
|
-
|
|
486
|
-
# restore original size if changed
|
|
487
|
-
try:
|
|
488
|
-
if orig_size is not None:
|
|
489
|
-
fig.set_size_inches(orig_size)
|
|
490
|
-
except Exception:
|
|
491
|
-
pass
|
|
492
|
-
|
|
493
|
-
buf.seek(0)
|
|
494
|
-
img_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
495
|
-
img_style = "max-width:100%;"
|
|
496
|
-
if specified_height is not None:
|
|
497
|
-
img_style = img_style + f" height:{specified_height}px;"
|
|
498
|
-
if specified_width is not None:
|
|
499
|
-
img_style = img_style + f" width:{specified_width}px;"
|
|
500
|
-
if specified_align not in ("left", "right", "center"):
|
|
501
|
-
specified_align = "center"
|
|
502
|
-
if specified_align == "center":
|
|
503
|
-
align_style = "display:flex; justify-content:center; align-items:center;"
|
|
504
|
-
elif specified_align == "left":
|
|
505
|
-
align_style = "display:flex; justify-content:flex-start; align-items:center;"
|
|
506
|
-
else:
|
|
507
|
-
align_style = "display:flex; justify-content:flex-end; align-items:center;"
|
|
508
|
-
elem = div(
|
|
509
|
-
raw_util(f'<img src="data:image/png;base64,{img_base64}" style="{img_style}">'),
|
|
510
|
-
style=align_style
|
|
511
|
-
)
|
|
512
|
-
except Exception as e:
|
|
513
|
-
elem = div(f"Matplotlib figure could not be rendered: {e}")
|
|
514
|
-
finally:
|
|
515
|
-
try:
|
|
516
|
-
buf.close()
|
|
517
|
-
except Exception:
|
|
518
|
-
pass
|
|
519
|
-
elif kind == "table":
|
|
520
|
-
df = content
|
|
521
|
-
html_table = df.to_html(classes="table-hover table-striped", index=False, border=0, table_id=f"table-{index}", escape=False)
|
|
522
|
-
elem = raw_util(html_table)
|
|
523
|
-
elif kind == "download":
|
|
524
|
-
file_path, label = content
|
|
525
|
-
btn = a(label or os.path.basename(file_path), href=file_path, cls="download-button", download=True)
|
|
526
|
-
elem = div(btn)
|
|
527
|
-
elif kind == "syntax":
|
|
528
|
-
code, language = content
|
|
529
|
-
elem = div(
|
|
530
|
-
raw_util(
|
|
531
|
-
f'<pre class="syntax-block"><code class="language-{language}">{html.escape(code)}</code></pre>'
|
|
532
|
-
),
|
|
533
|
-
cls="syntax-block"
|
|
534
|
-
)
|
|
535
|
-
elif kind == "minipage":
|
|
536
|
-
elem = content.render(
|
|
537
|
-
index,
|
|
538
|
-
downloads_dir=downloads_dir,
|
|
539
|
-
relative_prefix=relative_prefix,
|
|
540
|
-
inherited_width=effective_width,
|
|
541
|
-
inherited_marking=inherited_marking,
|
|
542
|
-
inherited_distribution=inherited_distribution
|
|
543
|
-
)
|
|
429
|
+
elem = self._render_element(
|
|
430
|
+
kind, content, index, downloads_dir, relative_prefix,
|
|
431
|
+
effective_width, inherited_marking, inherited_distribution
|
|
432
|
+
)
|
|
433
|
+
|
|
544
434
|
if el_width is not None:
|
|
545
|
-
elem = div(elem, style=
|
|
546
|
-
elem = div(elem, style=
|
|
547
|
-
|
|
548
|
-
row_div += cell
|
|
435
|
+
elem = div(elem, style=f"width: {el_width * 100}%;")
|
|
436
|
+
elem = div(elem, style="display: flex; justify-content: center; margin: 0 auto;")
|
|
437
|
+
|
|
438
|
+
row_div += div(elem, cls="minipage-cell")
|
|
439
|
+
|
|
549
440
|
return row_div
|
|
550
441
|
|
|
551
442
|
|
|
552
443
|
class Dashboard:
|
|
553
|
-
def __init__(self, title="Dashboard", page_width=
|
|
444
|
+
def __init__(self, title="Dashboard", page_width=DEFAULT_PAGE_WIDTH, marking=None, distribution=None):
|
|
554
445
|
self.title = title
|
|
555
446
|
self.pages = []
|
|
556
447
|
self.page_width = page_width
|
|
@@ -560,351 +451,258 @@ class Dashboard:
|
|
|
560
451
|
def add_page(self, page):
|
|
561
452
|
self.pages.append(page)
|
|
562
453
|
|
|
563
|
-
def
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
# <div class="sidebar-children"> ... </div>
|
|
570
|
-
# </div>
|
|
571
|
-
for page in pages:
|
|
572
|
-
page_href = f"{prefix}{page.slug}.html"
|
|
573
|
-
is_active = (page.slug == current_slug)
|
|
454
|
+
def _has_active_child(self, page, current_slug):
|
|
455
|
+
"""Check if page has an active child recursively."""
|
|
456
|
+
return any(
|
|
457
|
+
child.slug == current_slug or self._has_active_child(child, current_slug)
|
|
458
|
+
for child in getattr(page, "children", [])
|
|
459
|
+
)
|
|
574
460
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
461
|
+
def _get_page_href(self, page, prefix="", is_first_page=False, viewing_from_index=False):
|
|
462
|
+
"""
|
|
463
|
+
Get the href for a page, handling first page as index.html.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
page: The page to link to
|
|
467
|
+
prefix: The prefix for non-first pages ("pages/" or "")
|
|
468
|
+
is_first_page: Whether this is the first page (becomes index.html)
|
|
469
|
+
viewing_from_index: Whether we're viewing from index.html (vs pages/*.html)
|
|
470
|
+
"""
|
|
471
|
+
if is_first_page:
|
|
472
|
+
# First page is always index.html
|
|
473
|
+
if viewing_from_index:
|
|
474
|
+
return "index.html"
|
|
475
|
+
else:
|
|
476
|
+
return "../index.html"
|
|
477
|
+
else:
|
|
478
|
+
return f"{prefix}{page.slug}.html"
|
|
479
|
+
|
|
480
|
+
def _render_sidebar(self, pages, prefix="", current_slug=None, viewing_from_index=False, is_top_level=True):
|
|
481
|
+
"""Render sidebar navigation."""
|
|
482
|
+
for i, page in enumerate(pages):
|
|
483
|
+
# Only the first top-level page becomes index.html
|
|
484
|
+
is_first_page = (i == 0 and is_top_level)
|
|
485
|
+
page_href = self._get_page_href(page, prefix, is_first_page, viewing_from_index)
|
|
486
|
+
is_active = (page.slug == current_slug)
|
|
487
|
+
has_children = bool(getattr(page, "children", []))
|
|
488
|
+
group_open = self._has_active_child(page, current_slug)
|
|
580
489
|
|
|
581
|
-
group_open = has_active_child(page)
|
|
582
490
|
link_classes = "nav-link"
|
|
583
|
-
if
|
|
491
|
+
if has_children:
|
|
584
492
|
link_classes += " sidebar-parent"
|
|
585
493
|
if is_active:
|
|
586
494
|
link_classes += " active"
|
|
587
495
|
|
|
588
|
-
if
|
|
589
|
-
group_cls = "sidebar-group"
|
|
590
|
-
if group_open or is_active:
|
|
591
|
-
group_cls += " open"
|
|
496
|
+
if has_children:
|
|
497
|
+
group_cls = "sidebar-group open" if (group_open or is_active) else "sidebar-group"
|
|
592
498
|
with div(cls=group_cls):
|
|
593
|
-
a([
|
|
594
|
-
span("", cls="sidebar-arrow"), # pure-CSS triangle (no Unicode)
|
|
595
|
-
page.title
|
|
596
|
-
], cls=link_classes, href=page_href)
|
|
499
|
+
a([span("", cls="sidebar-arrow"), page.title], cls=link_classes, href=page_href)
|
|
597
500
|
with div(cls="sidebar-children"):
|
|
598
|
-
|
|
501
|
+
# Children are not top-level, so they won't be treated as index.html
|
|
502
|
+
self._render_sidebar(page.children, prefix, current_slug, viewing_from_index, is_top_level=False)
|
|
599
503
|
else:
|
|
600
504
|
a(page.title, cls="nav-link", href=page_href)
|
|
601
505
|
|
|
602
|
-
def
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
506
|
+
def _add_head_assets(self, head, rel_prefix, effective_width):
|
|
507
|
+
"""Add CSS, JavaScript, and other assets to document head."""
|
|
508
|
+
head.add(raw_util('<meta charset="utf-8" />'))
|
|
509
|
+
head.add(raw_util('<meta name="viewport" content="width=device-width, initial-scale=1" />'))
|
|
510
|
+
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/css/style.css"))
|
|
511
|
+
head.add(script(type="text/javascript", src=f"{rel_prefix}assets/js/script.js"))
|
|
512
|
+
|
|
513
|
+
# MathJax config
|
|
514
|
+
head.add(raw_util(
|
|
515
|
+
"<script>window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']],displayMath:[['$$','$$'],['\\\\[','\\\\]']]}};</script>"
|
|
516
|
+
))
|
|
517
|
+
head.add(raw_util(
|
|
518
|
+
f'<script src="{rel_prefix}assets/vendor/mathjax/tex-svg.js" '
|
|
519
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
520
|
+
's.src=\'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js\';'
|
|
521
|
+
'document.head.appendChild(s);"></script>'
|
|
522
|
+
))
|
|
523
|
+
|
|
524
|
+
# Prism syntax highlighting
|
|
525
|
+
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/vendor/prism/prism-tomorrow.min.css"))
|
|
526
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/prism.min.js"))
|
|
527
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-python.min.js"))
|
|
528
|
+
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-javascript.min.js"))
|
|
529
|
+
|
|
530
|
+
# Plotly with CDN fallback
|
|
531
|
+
head.add(raw_util(
|
|
532
|
+
f'<script src="{rel_prefix}assets/vendor/plotly/plotly.min.js" '
|
|
533
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
534
|
+
's.src=\'https://cdn.plot.ly/plotly-2.32.0.min.js\';'
|
|
535
|
+
'document.head.appendChild(s);"></script>'
|
|
536
|
+
))
|
|
537
|
+
|
|
538
|
+
# Mermaid.js for diagrams (local-first, CDN fallback)
|
|
539
|
+
head.add(raw_util(
|
|
540
|
+
f'<script src="{rel_prefix}assets/vendor/mermaid/mermaid.min.js" '
|
|
541
|
+
'onerror="var s=document.createElement(\'script\');'
|
|
542
|
+
's.src=\'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js\';'
|
|
543
|
+
's.onload=function(){{mermaid.initialize({{startOnLoad:true,theme:\'default\'}});}};'
|
|
544
|
+
'document.head.appendChild(s);"></script>'
|
|
545
|
+
))
|
|
546
|
+
head.add(raw_util(
|
|
547
|
+
'<script>if(typeof mermaid!=="undefined"){mermaid.initialize({ startOnLoad: true, theme: "default" });}</script>'
|
|
548
|
+
))
|
|
549
|
+
|
|
550
|
+
# CSS variables
|
|
551
|
+
head.add(raw_util(
|
|
552
|
+
f"<style>:root{{--sidebar-width:{DEFAULT_SIDEBAR_WIDTH}px;--content-padding-x:{DEFAULT_CONTENT_PADDING}px;}}</style>"
|
|
553
|
+
))
|
|
554
|
+
head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
|
|
555
|
+
|
|
556
|
+
def _write_page_html(self, page, output_dir, pages_dir, downloads_dir, rel_prefix="../"):
|
|
557
|
+
"""Write a single page HTML file."""
|
|
558
|
+
doc = document(title=page.title)
|
|
559
|
+
effective_width = page.page_width or self.page_width
|
|
608
560
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
612
|
-
|
|
613
|
-
def _add_head_assets(head, rel_prefix, effective_width):
|
|
614
|
-
# Ensure pages declare UTF-8 to avoid character misinterpretation
|
|
615
|
-
head.add(raw_util('<meta charset="utf-8" />'))
|
|
616
|
-
head.add(raw_util('<meta name="viewport" content="width=device-width, initial-scale=1" />'))
|
|
617
|
-
# Your CSS/JS
|
|
618
|
-
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/css/style.css"))
|
|
619
|
-
head.add(script(type="text/javascript", src=f"{rel_prefix}assets/js/script.js"))
|
|
620
|
-
|
|
621
|
-
# MathJax: config for $...$ and $$...$$
|
|
622
|
-
head.add(raw_util(
|
|
623
|
-
"<script>window.MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']],displayMath:[['$$','$$'],['\\\\[','\\\\]']]}};</script>"
|
|
624
|
-
))
|
|
625
|
-
# Local-first, CDN-fallback (for editable installs without vendored files)
|
|
626
|
-
head.add(raw_util(
|
|
627
|
-
f"<script src=\"{rel_prefix}assets/vendor/mathjax/tex-svg.js\" "
|
|
628
|
-
"onerror=\"var s=document.createElement('script');"
|
|
629
|
-
"s.src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js';"
|
|
630
|
-
"document.head.appendChild(s);\"></script>"
|
|
631
|
-
))
|
|
632
|
-
|
|
633
|
-
# Prism (theme + core + languages) — still local
|
|
634
|
-
head.add(link(rel="stylesheet", href=f"{rel_prefix}assets/vendor/prism/prism-tomorrow.min.css"))
|
|
635
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/prism.min.js"))
|
|
636
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-python.min.js"))
|
|
637
|
-
head.add(script(src=f"{rel_prefix}assets/vendor/prism/components/prism-javascript.min.js"))
|
|
638
|
-
|
|
639
|
-
# Plotly local-first, CDN-fallback
|
|
640
|
-
head.add(raw_util(
|
|
641
|
-
f"<script src=\"{rel_prefix}assets/vendor/plotly/plotly.min.js\" "
|
|
642
|
-
"onerror=\"var s=document.createElement('script');"
|
|
643
|
-
"s.src='https://cdn.plot.ly/plotly-2.32.0.min.js';"
|
|
644
|
-
"document.head.appendChild(s);\"></script>"
|
|
645
|
-
))
|
|
646
|
-
|
|
647
|
-
# Defaults that match your CSS; override in CSS if they change
|
|
648
|
-
head.add(raw_util("<style>:root{--sidebar-width:240px;--content-padding-x:20px;}</style>"))
|
|
649
|
-
head.add(raw_util(f"<style>.content-inner {{ max-width: {effective_width}px !important; }}</style>"))
|
|
650
|
-
|
|
651
|
-
def write_page(page):
|
|
652
|
-
doc = document(title=page.title)
|
|
653
|
-
effective_width = page.page_width or self.page_width or 900
|
|
654
|
-
with doc.head:
|
|
655
|
-
_add_head_assets(doc.head, rel_prefix="../", effective_width=effective_width)
|
|
656
|
-
with doc:
|
|
657
|
-
with div(id="sidebar"):
|
|
658
|
-
a(self.title, href="../index.html", cls="sidebar-title")
|
|
659
|
-
self._render_sidebar(self.pages, prefix="", current_slug=page.slug)
|
|
660
|
-
with div(id="sidebar-footer"):
|
|
661
|
-
a("Produced by staticdash", href="../index.html")
|
|
662
|
-
with div(id="content"):
|
|
663
|
-
with div(cls="content-inner"):
|
|
664
|
-
for el in page.render(
|
|
665
|
-
0,
|
|
666
|
-
downloads_dir=downloads_dir,
|
|
667
|
-
relative_prefix="../",
|
|
668
|
-
inherited_width=self.page_width,
|
|
669
|
-
inherited_marking=self.marking,
|
|
670
|
-
inherited_distribution=self.distribution
|
|
671
|
-
):
|
|
672
|
-
div(el)
|
|
673
|
-
|
|
674
|
-
with open(os.path.join(pages_dir, f"{page.slug}.html"), "w", encoding="utf-8") as f:
|
|
675
|
-
f.write(str(doc))
|
|
676
|
-
|
|
677
|
-
for child in getattr(page, "children", []):
|
|
678
|
-
write_page(child)
|
|
679
|
-
|
|
680
|
-
for page in self.pages:
|
|
681
|
-
write_page(page)
|
|
561
|
+
with doc.head:
|
|
562
|
+
self._add_head_assets(doc.head, rel_prefix=rel_prefix, effective_width=effective_width)
|
|
682
563
|
|
|
683
|
-
|
|
684
|
-
index_doc = document(title=self.title)
|
|
685
|
-
effective_width = self.pages[0].page_width or self.page_width or 900
|
|
686
|
-
with index_doc.head:
|
|
687
|
-
_add_head_assets(index_doc.head, rel_prefix="", effective_width=effective_width)
|
|
688
|
-
with index_doc:
|
|
564
|
+
with doc:
|
|
689
565
|
with div(id="sidebar"):
|
|
690
|
-
a(self.title, href="index.html", cls="sidebar-title")
|
|
691
|
-
|
|
566
|
+
a(self.title, href=f"{rel_prefix}index.html", cls="sidebar-title")
|
|
567
|
+
# When on index.html (rel_prefix=""), other pages are in pages/ directory
|
|
568
|
+
# When on pages/*.html (rel_prefix="../"), other pages are in same directory (no prefix needed)
|
|
569
|
+
viewing_from_index = (rel_prefix == "")
|
|
570
|
+
sidebar_prefix = "pages/" if viewing_from_index else ""
|
|
571
|
+
self._render_sidebar(self.pages, prefix=sidebar_prefix, current_slug=page.slug,
|
|
572
|
+
viewing_from_index=viewing_from_index)
|
|
692
573
|
with div(id="sidebar-footer"):
|
|
693
|
-
a("Produced by staticdash", href="index.html")
|
|
574
|
+
a("Produced by staticdash", href=f"{rel_prefix}index.html" if rel_prefix else "index.html")
|
|
694
575
|
with div(id="content"):
|
|
695
576
|
with div(cls="content-inner"):
|
|
696
|
-
for el in
|
|
577
|
+
for el in page.render(
|
|
697
578
|
0,
|
|
698
579
|
downloads_dir=downloads_dir,
|
|
699
|
-
relative_prefix=
|
|
580
|
+
relative_prefix=rel_prefix,
|
|
700
581
|
inherited_width=self.page_width,
|
|
701
582
|
inherited_marking=self.marking,
|
|
702
583
|
inherited_distribution=self.distribution
|
|
703
584
|
):
|
|
704
585
|
div(el)
|
|
705
586
|
|
|
706
|
-
|
|
707
|
-
|
|
587
|
+
# Determine output path: index.html for first page (rel_prefix=""), otherwise pages/{slug}.html
|
|
588
|
+
if rel_prefix == "":
|
|
589
|
+
output_path = os.path.join(output_dir, "index.html")
|
|
590
|
+
else:
|
|
591
|
+
output_path = os.path.join(pages_dir, f"{page.slug}.html")
|
|
592
|
+
|
|
593
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
594
|
+
f.write(str(doc))
|
|
595
|
+
|
|
596
|
+
def publish(self, output_dir="output"):
|
|
597
|
+
output_dir = os.path.abspath(output_dir)
|
|
598
|
+
pages_dir = os.path.join(output_dir, "pages")
|
|
599
|
+
downloads_dir = os.path.join(output_dir, "downloads")
|
|
600
|
+
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
601
|
+
assets_dst = os.path.join(output_dir, "assets")
|
|
602
|
+
|
|
603
|
+
os.makedirs(pages_dir, exist_ok=True)
|
|
604
|
+
os.makedirs(downloads_dir, exist_ok=True)
|
|
605
|
+
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
606
|
+
|
|
607
|
+
def write_page(page, is_first=False):
|
|
608
|
+
# First page goes to index.html, others go to pages/ directory
|
|
609
|
+
if is_first:
|
|
610
|
+
self._write_page_html(page, output_dir, pages_dir, downloads_dir, rel_prefix="")
|
|
611
|
+
else:
|
|
612
|
+
self._write_page_html(page, output_dir, pages_dir, downloads_dir)
|
|
613
|
+
|
|
614
|
+
# Recursively write children
|
|
615
|
+
for child in getattr(page, "children", []):
|
|
616
|
+
write_page(child, is_first=False)
|
|
617
|
+
|
|
618
|
+
# Write all pages
|
|
619
|
+
for i, page in enumerate(self.pages):
|
|
620
|
+
write_page(page, is_first=(i == 0))
|
|
708
621
|
|
|
709
622
|
|
|
710
623
|
class Directory:
|
|
711
624
|
"""
|
|
712
625
|
A Directory aggregates multiple Dashboard instances and publishes them
|
|
713
|
-
as a landing page listing multiple dashboards.
|
|
714
|
-
into its own subfolder under the output directory.
|
|
626
|
+
as a landing page listing multiple dashboards.
|
|
715
627
|
"""
|
|
716
628
|
|
|
717
|
-
def __init__(self, title="Dashboard Directory", page_width=
|
|
718
|
-
"""
|
|
719
|
-
Initialize a Directory.
|
|
720
|
-
|
|
721
|
-
Args:
|
|
722
|
-
title (str): The title of the directory landing page
|
|
723
|
-
page_width (int): The default page width for the landing page
|
|
724
|
-
"""
|
|
629
|
+
def __init__(self, title="Dashboard Directory", page_width=DEFAULT_PAGE_WIDTH):
|
|
725
630
|
self.title = title
|
|
726
631
|
self.page_width = page_width
|
|
727
632
|
self.dashboards = [] # List of (slug, dashboard) tuples
|
|
728
633
|
|
|
729
634
|
def add_dashboard(self, dashboard, slug=None):
|
|
730
|
-
"""
|
|
731
|
-
Add a Dashboard instance to the directory.
|
|
732
|
-
|
|
733
|
-
Args:
|
|
734
|
-
dashboard (Dashboard): The Dashboard instance to add
|
|
735
|
-
slug (str, optional): URL-friendly identifier for the dashboard.
|
|
736
|
-
If None, derived from dashboard title.
|
|
737
|
-
"""
|
|
635
|
+
"""Add a Dashboard instance to the directory."""
|
|
738
636
|
if slug is None:
|
|
739
|
-
|
|
740
|
-
slug = dashboard.title.lower().replace(" ", "-")
|
|
741
|
-
# Remove special characters
|
|
742
|
-
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
743
|
-
# Clean up multiple consecutive hyphens
|
|
744
|
-
slug = re.sub(r'-+', '-', slug)
|
|
745
|
-
# Remove leading/trailing hyphens
|
|
746
|
-
slug = slug.strip("-")
|
|
747
|
-
|
|
637
|
+
slug = self._generate_slug(dashboard.title)
|
|
748
638
|
self.dashboards.append((slug, dashboard))
|
|
749
639
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
Args:
|
|
758
|
-
output_dir (str): The output directory path
|
|
759
|
-
"""
|
|
760
|
-
output_dir = os.path.abspath(output_dir)
|
|
761
|
-
os.makedirs(output_dir, exist_ok=True)
|
|
762
|
-
|
|
763
|
-
# Copy assets to the root output directory
|
|
764
|
-
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
765
|
-
assets_dst = os.path.join(output_dir, "assets")
|
|
766
|
-
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
767
|
-
|
|
768
|
-
# Publish each dashboard to its own subfolder
|
|
769
|
-
for slug, dashboard in self.dashboards:
|
|
770
|
-
dashboard_dir = os.path.join(output_dir, slug)
|
|
771
|
-
dashboard.publish(output_dir=dashboard_dir)
|
|
772
|
-
|
|
773
|
-
# Add a "Back to Directory" link to each dashboard's index page
|
|
774
|
-
self._add_back_link(dashboard_dir, slug)
|
|
775
|
-
|
|
776
|
-
# Create the landing page
|
|
777
|
-
self._create_landing_page(output_dir)
|
|
778
|
-
|
|
779
|
-
def _add_back_link(self, dashboard_dir, slug):
|
|
780
|
-
"""
|
|
781
|
-
Add a navigation link back to the directory landing page in the dashboard.
|
|
782
|
-
|
|
783
|
-
Args:
|
|
784
|
-
dashboard_dir (str): Path to the dashboard output directory
|
|
785
|
-
slug (str): The slug of the dashboard
|
|
786
|
-
"""
|
|
787
|
-
index_path = os.path.join(dashboard_dir, "index.html")
|
|
788
|
-
if not os.path.exists(index_path):
|
|
789
|
-
return
|
|
790
|
-
|
|
791
|
-
# Read the existing index.html
|
|
792
|
-
with open(index_path, "r", encoding="utf-8") as f:
|
|
793
|
-
content = f.read()
|
|
794
|
-
|
|
795
|
-
# Add a back link in the sidebar footer
|
|
796
|
-
# Replace the sidebar-footer section with one that includes a back link
|
|
797
|
-
back_link = '<div id="sidebar-footer"><a href="../index.html">← Back to Directory</a></div>'
|
|
798
|
-
|
|
799
|
-
# Find and replace the sidebar-footer
|
|
800
|
-
pattern = r'<div id="sidebar-footer">.*?</div>'
|
|
801
|
-
content = re.sub(pattern, back_link, content, flags=re.DOTALL)
|
|
802
|
-
|
|
803
|
-
# Write back the modified content
|
|
804
|
-
with open(index_path, "w", encoding="utf-8") as f:
|
|
805
|
-
f.write(content)
|
|
806
|
-
|
|
807
|
-
# Also update all page HTML files to have the back link
|
|
808
|
-
pages_dir = os.path.join(dashboard_dir, "pages")
|
|
809
|
-
if os.path.exists(pages_dir):
|
|
810
|
-
for page_file in os.listdir(pages_dir):
|
|
811
|
-
if page_file.endswith(".html"):
|
|
812
|
-
page_path = os.path.join(pages_dir, page_file)
|
|
813
|
-
with open(page_path, "r", encoding="utf-8") as f:
|
|
814
|
-
page_content = f.read()
|
|
815
|
-
|
|
816
|
-
# For pages, the back link needs to go up two levels
|
|
817
|
-
back_link_pages = '<div id="sidebar-footer"><a href="../../index.html">← Back to Directory</a></div>'
|
|
818
|
-
page_content = re.sub(pattern, back_link_pages, page_content, flags=re.DOTALL)
|
|
819
|
-
|
|
820
|
-
with open(page_path, "w", encoding="utf-8") as f:
|
|
821
|
-
f.write(page_content)
|
|
640
|
+
@staticmethod
|
|
641
|
+
def _generate_slug(title):
|
|
642
|
+
"""Generate a URL-friendly slug from a title."""
|
|
643
|
+
slug = title.lower().replace(" ", "-")
|
|
644
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
645
|
+
slug = re.sub(r'-+', '-', slug)
|
|
646
|
+
return slug.strip("-")
|
|
822
647
|
|
|
823
648
|
def _create_landing_page(self, output_dir):
|
|
824
|
-
"""
|
|
825
|
-
Create the landing page HTML that lists all dashboards.
|
|
826
|
-
|
|
827
|
-
Args:
|
|
828
|
-
output_dir (str): Path to the output directory
|
|
829
|
-
"""
|
|
649
|
+
"""Create the landing page HTML that lists all dashboards."""
|
|
830
650
|
doc = document(title=self.title)
|
|
831
651
|
|
|
832
|
-
# Add CSS and basic styling
|
|
833
652
|
with doc.head:
|
|
834
|
-
# Ensure charset is declared for the landing page too
|
|
835
653
|
doc.head.add(raw_util('<meta charset="utf-8" />'))
|
|
836
654
|
link(rel="stylesheet", href="assets/css/style.css")
|
|
837
|
-
raw_util("""
|
|
655
|
+
raw_util(f"""
|
|
838
656
|
<style>
|
|
839
|
-
body {
|
|
840
|
-
font-family:
|
|
841
|
-
margin: 0;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
.directory-header {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
color: #333;
|
|
858
|
-
}
|
|
859
|
-
.directory-header p {
|
|
860
|
-
font-size: 1.2em;
|
|
861
|
-
color: #666;
|
|
862
|
-
}
|
|
863
|
-
.dashboard-grid {
|
|
657
|
+
body {{
|
|
658
|
+
font-family: {DEFAULT_FONT_FAMILY};
|
|
659
|
+
margin: 0; padding: 0; background-color: #f5f5f5;
|
|
660
|
+
}}
|
|
661
|
+
.directory-container {{
|
|
662
|
+
max-width: {self.page_width}px;
|
|
663
|
+
margin: 0 auto; padding: 40px 20px;
|
|
664
|
+
}}
|
|
665
|
+
.directory-header {{
|
|
666
|
+
text-align: center; margin-bottom: 50px;
|
|
667
|
+
}}
|
|
668
|
+
.directory-header h1 {{
|
|
669
|
+
font-size: 2.5em; margin-bottom: 10px; color: #333;
|
|
670
|
+
}}
|
|
671
|
+
.directory-header p {{
|
|
672
|
+
font-size: 1.2em; color: #666;
|
|
673
|
+
}}
|
|
674
|
+
.dashboard-grid {{
|
|
864
675
|
display: grid;
|
|
865
676
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
866
|
-
gap: 30px;
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
background: white;
|
|
871
|
-
border-radius: 8px;
|
|
872
|
-
padding: 30px;
|
|
677
|
+
gap: 30px; margin-top: 30px;
|
|
678
|
+
}}
|
|
679
|
+
.dashboard-card {{
|
|
680
|
+
background: white; border-radius: 8px; padding: 30px;
|
|
873
681
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
874
682
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
875
|
-
text-decoration: none;
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
879
|
-
.dashboard-card:hover {
|
|
683
|
+
text-decoration: none; color: inherit; display: block;
|
|
684
|
+
}}
|
|
685
|
+
.dashboard-card:hover {{
|
|
880
686
|
transform: translateY(-4px);
|
|
881
687
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
882
|
-
}
|
|
883
|
-
.dashboard-card h2 {
|
|
884
|
-
margin: 0 0 10px 0;
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
font-size: 0.95em;
|
|
892
|
-
}
|
|
893
|
-
.dashboard-arrow {
|
|
894
|
-
display: inline-block;
|
|
895
|
-
margin-left: 5px;
|
|
688
|
+
}}
|
|
689
|
+
.dashboard-card h2 {{
|
|
690
|
+
margin: 0 0 10px 0; font-size: 1.5em; color: #2c3e50;
|
|
691
|
+
}}
|
|
692
|
+
.dashboard-card p {{
|
|
693
|
+
margin: 0; color: #7f8c8d; font-size: 0.95em;
|
|
694
|
+
}}
|
|
695
|
+
.dashboard-arrow {{
|
|
696
|
+
display: inline-block; margin-left: 5px;
|
|
896
697
|
transition: transform 0.2s;
|
|
897
|
-
}
|
|
898
|
-
.dashboard-card:hover .dashboard-arrow {
|
|
698
|
+
}}
|
|
699
|
+
.dashboard-card:hover .dashboard-arrow {{
|
|
899
700
|
transform: translateX(5px);
|
|
900
|
-
}
|
|
901
|
-
.footer {
|
|
902
|
-
text-align: center;
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
color: #999;
|
|
906
|
-
font-size: 0.9em;
|
|
907
|
-
}
|
|
701
|
+
}}
|
|
702
|
+
.footer {{
|
|
703
|
+
text-align: center; margin-top: 60px; padding: 20px;
|
|
704
|
+
color: #999; font-size: 0.9em;
|
|
705
|
+
}}
|
|
908
706
|
</style>
|
|
909
707
|
""")
|
|
910
708
|
|
|
@@ -912,19 +710,38 @@ class Directory:
|
|
|
912
710
|
with div(cls="directory-container"):
|
|
913
711
|
with div(cls="directory-header"):
|
|
914
712
|
h1(self.title)
|
|
915
|
-
|
|
713
|
+
plural = 's' if len(self.dashboards) != 1 else ''
|
|
714
|
+
p(f"Explore {len(self.dashboards)} dashboard{plural}")
|
|
916
715
|
|
|
917
716
|
with div(cls="dashboard-grid"):
|
|
918
717
|
for slug, dashboard in self.dashboards:
|
|
919
|
-
with a(href=f"{slug}/index.html", cls="dashboard-card"):
|
|
718
|
+
with a(href=f"{slug}/index.html", cls="dashboard-card", target="_blank", rel="noopener noreferrer"):
|
|
920
719
|
h2(dashboard.title)
|
|
921
720
|
num_pages = len(dashboard.pages)
|
|
922
|
-
|
|
721
|
+
plural = 's' if num_pages != 1 else ''
|
|
722
|
+
p(f"{num_pages} page{plural} ")
|
|
923
723
|
span("→", cls="dashboard-arrow")
|
|
924
724
|
|
|
925
725
|
with div(cls="footer"):
|
|
926
726
|
p("Produced by staticdash")
|
|
927
727
|
|
|
928
|
-
# Write the landing page
|
|
929
728
|
with open(os.path.join(output_dir, "index.html"), "w", encoding="utf-8") as f:
|
|
930
729
|
f.write(str(doc))
|
|
730
|
+
|
|
731
|
+
def publish(self, output_dir="output"):
|
|
732
|
+
"""Publish the directory landing page and all dashboards."""
|
|
733
|
+
output_dir = os.path.abspath(output_dir)
|
|
734
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
735
|
+
|
|
736
|
+
# Copy assets to root
|
|
737
|
+
assets_src = os.path.join(os.path.dirname(__file__), "assets")
|
|
738
|
+
assets_dst = os.path.join(output_dir, "assets")
|
|
739
|
+
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
740
|
+
|
|
741
|
+
# Publish each dashboard independently (no modifications)
|
|
742
|
+
for slug, dashboard in self.dashboards:
|
|
743
|
+
dashboard_dir = os.path.join(output_dir, slug)
|
|
744
|
+
dashboard.publish(output_dir=dashboard_dir)
|
|
745
|
+
|
|
746
|
+
# Create the directory landing page
|
|
747
|
+
self._create_landing_page(output_dir)
|