Skip to main content

candle_graph/
viewer.rs

1//! Dependency-free interactive HTML visualizer for `candle-graph/viewer/1` payloads.
2//!
3//! Embeds escaped JSON, CSS, and JS into one document. No CDN or network fetches.
4
5use serde_json::Value;
6
7const CSS: &str = include_str!("viewer/style.css");
8const JS: &str = include_str!("viewer/app.js");
9
10/// Render a complete standalone HTML document from a viewer projection.
11pub fn render_html(projection: &Value) -> String {
12    let payload = embed_json(projection);
13    let mut html = String::with_capacity(8192 + CSS.len() + JS.len() + payload.len());
14    html.push_str("<!DOCTYPE html>\n<html lang=\"en\" data-viewer=\"candle-graph\">\n<head>\n");
15    html.push_str("<meta charset=\"utf-8\"/>\n");
16    html.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>\n");
17    html.push_str("<title>candle-graph visualizer</title>\n<style>");
18    html.push_str(CSS);
19    html.push_str("</style>\n</head>\n<body>\n");
20    html.push_str("<a class=\"skip\" href=\"#canvas-pane\">Skip to canvas</a>\n");
21
22    // Header
23    html.push_str("<header class=\"top\" role=\"banner\">\n");
24    html.push_str("  <div class=\"brand\"><span class=\"brand-mark\" aria-hidden=\"true\"></span>candle-graph</div>\n");
25    html.push_str("  <div class=\"cov\" data-coverage role=\"status\" aria-live=\"polite\"></div>\n");
26    html.push_str("  <div class=\"top-actions\">\n");
27    html.push_str("    <button type=\"button\" class=\"btn\" id=\"print-btn\" aria-label=\"Toggle publication preview\">Preview</button>\n");
28    html.push_str("    <button type=\"button\" class=\"btn primary\" id=\"export-btn\" aria-label=\"Export graph as SVG\">Export SVG</button>\n");
29    html.push_str("    <button type=\"button\" class=\"btn\" id=\"theme-btn\" data-theme-toggle aria-label=\"Toggle color theme\">Theme</button>\n");
30    html.push_str("  </div>\n");
31    html.push_str("</header>\n");
32
33    // Layout with resizable panes
34    html.push_str("<div class=\"layout\" id=\"app\">\n");
35
36    // Sidebar
37    html.push_str("  <nav class=\"pane pane-sidebar\" data-pane=\"sidebar\" aria-label=\"Navigation\">\n");
38    html.push_str("    <div class=\"pane-h\">Views</div>\n");
39    html.push_str("    <div class=\"tabs\" data-view-tabs role=\"tablist\" aria-label=\"Graph views\"></div>\n");
40    html.push_str("    <div class=\"search-box\" id=\"search-section\">\n");
41    html.push_str("      <label class=\"sr\" for=\"node-search\">Search graph nodes</label>\n");
42    html.push_str("      <input id=\"node-search\" type=\"search\" placeholder=\"Search nodes…\" autocomplete=\"off\"/>\n");
43    html.push_str("    </div>\n");
44    html.push_str("    <div id=\"tree-section\">\n");
45    html.push_str("      <div class=\"pane-h sub\">Module tree</div>\n");
46    html.push_str("      <div class=\"search-box\">\n");
47    html.push_str("        <label class=\"sr\" for=\"mod-search\">Search modules</label>\n");
48    html.push_str("        <input id=\"mod-search\" data-module-search type=\"search\" placeholder=\"Filter modules…\" autocomplete=\"off\"/>\n");
49    html.push_str("      </div>\n");
50    html.push_str("      <div id=\"module-tree\" class=\"scroll-area\" data-module-tree tabindex=\"0\" role=\"tree\" aria-label=\"Modules\"></div>\n");
51    html.push_str("    </div>\n");
52    html.push_str("    <div class=\"pane-h sub\">Findings</div>\n");
53    html.push_str("    <div id=\"findings-list\" class=\"scroll-area\" data-findings-list role=\"list\" aria-label=\"Findings\"></div>\n");
54    html.push_str("    <div class=\"diag\" data-diagnostics aria-label=\"Diagnostics\"></div>\n");
55    html.push_str("  </nav>\n");
56
57    html.push_str("  <div class=\"resize-handle\" data-side=\"left\" aria-hidden=\"true\"></div>\n");
58
59    // Canvas
60    html.push_str("  <main class=\"pane pane-canvas\" data-pane=\"canvas\" id=\"canvas-pane\" aria-label=\"Graph canvas\">\n");
61    html.push_str("    <div class=\"pane-h row\"><span data-canvas-title>Graph</span><span class=\"graph-stats\" data-graph-stats></span></div>\n");
62    html.push_str("    <div class=\"graph-toolbar\" id=\"graph-toolbar\" hidden>\n");
63    html.push_str("      <label class=\"sr\" for=\"fn-filter\">Function</label>\n");
64    html.push_str("      <select id=\"fn-filter\" data-function-filter aria-label=\"Filter by function\"></select>\n");
65    html.push_str("      <button type=\"button\" class=\"btn\" id=\"fit-btn\" aria-label=\"Fit graph to view\">Fit</button>\n");
66    html.push_str("      <button type=\"button\" class=\"btn\" id=\"reset-btn\" aria-label=\"Reset zoom and pan\">Reset</button>\n");
67    html.push_str("    </div>\n");
68    html.push_str("    <div class=\"graph-banner\" id=\"graph-banner\" hidden role=\"status\"></div>\n");
69    html.push_str("    <div class=\"canvas-wrap\" id=\"canvas-wrap\">\n");
70    html.push_str("      <div id=\"empty-graph\" class=\"empty\" data-empty-state hidden>\n");
71    html.push_str("        <p><strong>No graph data</strong></p>\n");
72    html.push_str("        <p>This view has no nodes to display for the current analysis.</p>\n");
73    html.push_str("      </div>\n");
74    html.push_str("      <svg id=\"graph-canvas\" data-canvas role=\"img\" aria-label=\"Graph\" tabindex=\"0\"></svg>\n");
75    html.push_str("      <div id=\"graph-tooltip\" class=\"graph-tooltip\" role=\"tooltip\" aria-hidden=\"true\"></div>\n");
76    html.push_str("      <div class=\"legend-float\" data-legend role=\"list\" aria-label=\"Graph legend\">\n");
77    html.push_str("        <button type=\"button\" class=\"legend-toggle\" id=\"legend-toggle\" aria-expanded=\"true\">Legend</button>\n");
78    html.push_str("        <div class=\"legend-body\">\n");
79    html.push_str("          <span role=\"listitem\" class=\"lg kind-op\">Operation</span>\n");
80    html.push_str("          <span role=\"listitem\" class=\"lg kind-param\">Parameter</span>\n");
81    html.push_str("          <span role=\"listitem\" class=\"lg kind-tensor\">Tensor</span>\n");
82    html.push_str("          <span role=\"listitem\" class=\"lg kind-module\">Module</span>\n");
83    for state in [
84        "Trainable",
85        "Frozen",
86        "Differentiable",
87        "Severed",
88        "LayoutDependent",
89        "Unknown",
90    ] {
91        html.push_str(&format!(
92            "          <span role=\"listitem\" data-legend-item=\"{state}\" class=\"lg {state}\">{state}</span>\n"
93        ));
94    }
95    html.push_str("        </div>\n");
96    html.push_str("      </div>\n");
97    html.push_str("      <div class=\"canvas-controls\" aria-label=\"Zoom controls\">\n");
98    html.push_str("        <button type=\"button\" class=\"btn icon\" id=\"zoom-in\" aria-label=\"Zoom in\" title=\"Zoom in (+)\">+</button>\n");
99    html.push_str("        <div class=\"zoom-label\" id=\"zoom-label\">100%</div>\n");
100    html.push_str("        <button type=\"button\" class=\"btn icon\" id=\"zoom-out\" aria-label=\"Zoom out\" title=\"Zoom out (−)\">−</button>\n");
101    html.push_str("        <button type=\"button\" class=\"btn icon\" id=\"zoom-fit\" aria-label=\"Fit to view\" title=\"Fit (F)\">⤢</button>\n");
102    html.push_str("      </div>\n");
103    html.push_str("      <p class=\"canvas-hint\" id=\"canvas-hint\"><kbd>U</kbd> clear · click background · <kbd>F</kbd> fit · <kbd>+</kbd><kbd>−</kbd> zoom</p>\n");
104    html.push_str("    </div>\n");
105    html.push_str("  </main>\n");
106
107    html.push_str("  <div class=\"resize-handle\" data-side=\"right\" aria-hidden=\"true\"></div>\n");
108
109    // Inspector
110    html.push_str("  <aside class=\"pane pane-inspector\" data-pane=\"inspector\" aria-label=\"Inspector\">\n");
111    html.push_str("    <div class=\"pane-h\">Inspector</div>\n");
112    html.push_str("    <dl id=\"inspector\" data-inspector>\n");
113    html.push_str("      <div class=\"section-title\">Identity</div>\n");
114    for (field, title) in [
115        ("label", "Label"),
116        ("kind", "Kind"),
117        ("qualified", "Qualified name"),
118        ("source", "Source"),
119    ] {
120        html.push_str(&format!(
121            "      <div><dt>{title}</dt><dd data-field=\"{field}\">—</dd></div>\n"
122        ));
123    }
124    html.push_str("      <div class=\"section-title\">Tensor</div>\n");
125    for (field, title) in [
126        ("shape", "Shape"),
127        ("dtype", "Dtype"),
128        ("grad", "Grad state"),
129        ("role", "Role"),
130    ] {
131        html.push_str(&format!(
132            "      <div><dt>{title}</dt><dd data-field=\"{field}\">—</dd></div>\n"
133        ));
134    }
135    html.push_str("      <div class=\"section-title\">Analysis</div>\n");
136    for (field, title) in [
137        ("root", "Builder root"),
138        ("confidence", "Confidence"),
139        ("timing", "Timing (avg · min–max)"),
140        ("function", "Function"),
141        ("severity", "Severity"),
142        ("rule", "Rule"),
143    ] {
144        html.push_str(&format!(
145            "      <div><dt>{title}</dt><dd data-field=\"{field}\">—</dd></div>\n"
146        ));
147    }
148    html.push_str("    </dl>\n");
149    html.push_str("  </aside>\n");
150    html.push_str("</div>\n");
151
152    html.push_str("<script id=\"cg-payload\" type=\"application/json\">");
153    html.push_str(&payload);
154    html.push_str("</script>\n<script>");
155    html.push_str(include_str!("viewer/dagre.min.js"));
156    html.push_str("</script>\n<script>");
157    html.push_str(include_str!("viewer/layout.js"));
158    html.push_str("</script>\n<script>");
159    html.push_str(JS);
160    html.push_str("</script>\n</body>\n</html>\n");
161    html
162}
163
164/// Serialize JSON and escape so embedded payload cannot close a surrounding `<script>` tag.
165pub fn embed_json(value: &Value) -> String {
166    escape_for_script(&value.to_string())
167}
168
169/// Escape text for safe inclusion inside HTML `<script>` content.
170pub fn escape_for_script(s: &str) -> String {
171    let mut out = String::with_capacity(s.len());
172    for ch in s.chars() {
173        match ch {
174            '<' => out.push_str("\\u003c"),
175            '>' => out.push_str("\\u003e"),
176            '\u{2028}' => out.push_str("\\u2028"),
177            '\u{2029}' => out.push_str("\\u2029"),
178            _ => out.push(ch),
179        }
180    }
181    out
182}