@shumoku/renderer 0.2.1 → 0.2.4

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.
Files changed (46) hide show
  1. package/dist/html/index.d.ts +25 -0
  2. package/dist/html/index.d.ts.map +1 -1
  3. package/dist/html/index.js +742 -158
  4. package/dist/html/index.js.map +1 -1
  5. package/dist/html/navigation.d.ts +54 -0
  6. package/dist/html/navigation.d.ts.map +1 -0
  7. package/dist/html/navigation.js +210 -0
  8. package/dist/html/navigation.js.map +1 -0
  9. package/dist/html/runtime.d.ts +2 -1
  10. package/dist/html/runtime.d.ts.map +1 -1
  11. package/dist/html/runtime.js +245 -482
  12. package/dist/html/runtime.js.map +1 -1
  13. package/dist/html/spotlight.d.ts +9 -0
  14. package/dist/html/spotlight.d.ts.map +1 -0
  15. package/dist/html/spotlight.js +119 -0
  16. package/dist/html/spotlight.js.map +1 -0
  17. package/dist/html/tooltip.d.ts +14 -0
  18. package/dist/html/tooltip.d.ts.map +1 -0
  19. package/dist/html/tooltip.js +133 -0
  20. package/dist/html/tooltip.js.map +1 -0
  21. package/dist/html/viewbox.d.ts +14 -0
  22. package/dist/html/viewbox.d.ts.map +1 -0
  23. package/dist/html/viewbox.js +21 -0
  24. package/dist/html/viewbox.js.map +1 -0
  25. package/dist/iife-string.d.ts +2 -0
  26. package/dist/iife-string.js +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/shumoku-interactive.iife.js +25 -20
  30. package/dist/svg.d.ts +27 -0
  31. package/dist/svg.d.ts.map +1 -1
  32. package/dist/svg.js +202 -101
  33. package/dist/svg.js.map +1 -1
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +5 -2
  37. package/src/build-iife-string.ts +26 -19
  38. package/src/html/index.ts +880 -226
  39. package/src/html/navigation.ts +256 -0
  40. package/src/html/runtime.ts +412 -654
  41. package/src/html/spotlight.ts +135 -0
  42. package/src/html/tooltip.ts +141 -0
  43. package/src/html/viewbox.ts +28 -0
  44. package/src/index.ts +25 -22
  45. package/src/svg.ts +1640 -1502
  46. package/src/types.ts +127 -125
package/src/html/index.ts CHANGED
@@ -1,226 +1,880 @@
1
- /**
2
- * HTML Renderer
3
- * Generates standalone interactive HTML pages from NetworkGraph
4
- */
5
-
6
- import type { LayoutResult, NetworkGraph } from '@shumoku/core'
7
- import { SVGRenderer } from '../svg.js'
8
- import type { HTMLRendererOptions } from '../types.js'
9
-
10
- export type { InteractiveInstance, InteractiveOptions } from '../types.js'
11
- // Re-export runtime for direct usage
12
- export { initInteractive } from './runtime.js'
13
-
14
- // IIFE content - will be set by consumer
15
- let INTERACTIVE_IIFE = ''
16
-
17
- /**
18
- * Set the IIFE content for standalone HTML pages
19
- */
20
- export function setIIFE(iife: string): void {
21
- INTERACTIVE_IIFE = iife
22
- }
23
-
24
- /**
25
- * Get the current IIFE content
26
- */
27
- export function getIIFE(): string {
28
- return INTERACTIVE_IIFE
29
- }
30
-
31
- export interface RenderOptions extends HTMLRendererOptions {}
32
-
33
- const DEFAULT_OPTIONS = {
34
- branding: true,
35
- toolbar: true,
36
- }
37
-
38
- /**
39
- * Render a complete standalone HTML page from NetworkGraph
40
- */
41
- export function render(graph: NetworkGraph, layout: LayoutResult, options?: RenderOptions): string {
42
- const opts = { ...DEFAULT_OPTIONS, ...options }
43
- const svgRenderer = new SVGRenderer({ renderMode: 'interactive' })
44
- const svg = svgRenderer.render(graph, layout)
45
- const title = options?.title || graph.name || 'Network Diagram'
46
-
47
- return generateHtml(svg, title, opts as Required<RenderOptions>)
48
- }
49
-
50
- function generateHtml(svg: string, title: string, options: Required<RenderOptions>): string {
51
- const brandingHtml = options.branding
52
- ? `<a class="branding" href="https://shumoku.packof.me" target="_blank" rel="noopener">
53
- <svg class="branding-icon" viewBox="0 0 1024 1024" fill="none"><rect x="64" y="64" width="896" height="896" rx="200" fill="#7FE4C1"/><g transform="translate(90,40) scale(1.25)"><path fill="#1F2328" d="M380 340H450V505H700V555H510V645H450V645H380Z"/></g></svg>
54
- <span>Made with Shumoku</span>
55
- </a>`
56
- : ''
57
-
58
- const toolbarHtml = options.toolbar
59
- ? `<div class="toolbar">
60
- <span class="toolbar-title">${escapeHtml(title)}</span>
61
- <div class="toolbar-buttons">
62
- <button id="btn-out" title="Zoom Out"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M20 12H4"/></svg></button>
63
- <span class="zoom-text" id="zoom">100%</span>
64
- <button id="btn-in" title="Zoom In"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg></button>
65
- <button id="btn-fit" title="Fit"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/></svg></button>
66
- <button id="btn-reset" title="Reset"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
67
- </div>
68
- </div>`
69
- : ''
70
-
71
- return `<!DOCTYPE html>
72
- <html>
73
- <head>
74
- <meta charset="UTF-8">
75
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
76
- <title>${escapeHtml(title)}</title>
77
- <style>
78
- * { margin: 0; padding: 0; box-sizing: border-box; }
79
- body { background: #f5f5f5; min-height: 100vh; }
80
- .toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: white; border-bottom: 1px solid #e5e5e5; }
81
- .toolbar-title { font-size: 14px; color: #666; }
82
- .toolbar-buttons { display: flex; gap: 4px; align-items: center; }
83
- .toolbar button { padding: 6px; border: none; background: none; cursor: pointer; border-radius: 4px; color: #666; }
84
- .toolbar button:hover { background: #f0f0f0; }
85
- .zoom-text { min-width: 50px; text-align: center; font-size: 13px; color: #666; }
86
- .container { position: relative; width: 100%; height: ${options.toolbar ? 'calc(100vh - 45px)' : '100vh'}; overflow: hidden; cursor: grab; background: repeating-conic-gradient(#f8f8f8 0% 25%, transparent 0% 50%) 50% / 20px 20px; }
87
- .container.dragging { cursor: grabbing; }
88
- .container > svg { width: 100%; height: 100%; }
89
- .branding { position: absolute; bottom: 16px; right: 16px; display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; font-size: 13px; font-family: system-ui, sans-serif; color: #555; text-decoration: none; transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }
90
- .branding:hover { color: #222; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
91
- .branding-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
92
- /* SVG interactive styles */
93
- .node { cursor: pointer; }
94
- .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
95
- .port { cursor: pointer; }
96
- .link-hit-area { cursor: pointer; }
97
- </style>
98
- </head>
99
- <body>
100
- ${toolbarHtml}
101
- <div class="container" id="container">
102
- ${svg}
103
- ${brandingHtml}
104
- </div>
105
- <script>${INTERACTIVE_IIFE}</script>
106
- <script>
107
- (function() {
108
- var svg = document.querySelector('#container > svg');
109
- var container = document.getElementById('container');
110
- if (!svg || !container) { console.error('SVG or container not found'); return; }
111
-
112
- var vb = { x: 0, y: 0, w: 0, h: 0 };
113
- var origVb = { x: 0, y: 0, w: 0, h: 0 };
114
- var drag = { active: false, x: 0, y: 0, vx: 0, vy: 0 };
115
-
116
- function init() {
117
- var w = parseFloat(svg.getAttribute('width')) || 800;
118
- var h = parseFloat(svg.getAttribute('height')) || 600;
119
- var existing = svg.getAttribute('viewBox');
120
- if (existing) {
121
- var p = existing.split(/\\s+|,/).map(Number);
122
- origVb = { x: p[0] || 0, y: p[1] || 0, w: p[2] || w, h: p[3] || h };
123
- } else {
124
- origVb = { x: 0, y: 0, w: w, h: h };
125
- }
126
- svg.removeAttribute('width');
127
- svg.removeAttribute('height');
128
- svg.style.width = '100%';
129
- svg.style.height = '100%';
130
- fitView();
131
-
132
- if (window.ShumokuInteractive) {
133
- window.ShumokuInteractive.initInteractive({ target: svg, modal: { enabled: true }, tooltip: { enabled: true }, panZoom: { enabled: false } });
134
- }
135
- }
136
-
137
- function updateViewBox() {
138
- svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h);
139
- var zoomEl = document.getElementById('zoom');
140
- if (zoomEl) zoomEl.textContent = Math.round(origVb.w / vb.w * 100) + '%';
141
- }
142
-
143
- function fitView() {
144
- var cw = container.clientWidth || 800;
145
- var ch = container.clientHeight || 600;
146
- var scale = Math.min(cw / origVb.w, ch / origVb.h) * 0.9;
147
- vb.w = cw / scale;
148
- vb.h = ch / scale;
149
- vb.x = origVb.x + (origVb.w - vb.w) / 2;
150
- vb.y = origVb.y + (origVb.h - vb.h) / 2;
151
- updateViewBox();
152
- }
153
-
154
- function resetView() {
155
- vb.x = origVb.x; vb.y = origVb.y; vb.w = origVb.w; vb.h = origVb.h;
156
- updateViewBox();
157
- }
158
-
159
- function zoom(f) {
160
- var cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
161
- var nw = vb.w / f, nh = vb.h / f;
162
- var scale = origVb.w / nw;
163
- if (scale < 0.1 || scale > 10) return;
164
- vb.w = nw; vb.h = nh; vb.x = cx - nw / 2; vb.y = cy - nh / 2;
165
- updateViewBox();
166
- }
167
-
168
- var btnIn = document.getElementById('btn-in');
169
- var btnOut = document.getElementById('btn-out');
170
- var btnFit = document.getElementById('btn-fit');
171
- var btnReset = document.getElementById('btn-reset');
172
- if (btnIn) btnIn.addEventListener('click', function() { zoom(1.2); });
173
- if (btnOut) btnOut.addEventListener('click', function() { zoom(1/1.2); });
174
- if (btnFit) btnFit.addEventListener('click', fitView);
175
- if (btnReset) btnReset.addEventListener('click', resetView);
176
-
177
- container.addEventListener('wheel', function(e) {
178
- e.preventDefault();
179
- var rect = container.getBoundingClientRect();
180
- var mx = (e.clientX - rect.left) / rect.width;
181
- var my = (e.clientY - rect.top) / rect.height;
182
- var px = vb.x + vb.w * mx, py = vb.y + vb.h * my;
183
- var f = e.deltaY > 0 ? 1/1.2 : 1.2;
184
- var nw = vb.w / f, nh = vb.h / f;
185
- var scale = origVb.w / nw;
186
- if (scale < 0.1 || scale > 10) return;
187
- vb.w = nw; vb.h = nh; vb.x = px - nw * mx; vb.y = py - nh * my;
188
- updateViewBox();
189
- }, { passive: false });
190
-
191
- container.addEventListener('mousedown', function(e) {
192
- if (e.button === 0) {
193
- drag = { active: true, x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y };
194
- container.classList.add('dragging');
195
- }
196
- });
197
-
198
- document.addEventListener('mousemove', function(e) {
199
- if (!drag.active) return;
200
- var sx = vb.w / container.clientWidth;
201
- var sy = vb.h / container.clientHeight;
202
- vb.x = drag.vx - (e.clientX - drag.x) * sx;
203
- vb.y = drag.vy - (e.clientY - drag.y) * sy;
204
- updateViewBox();
205
- });
206
-
207
- document.addEventListener('mouseup', function() {
208
- drag.active = false;
209
- container.classList.remove('dragging');
210
- });
211
-
212
- init();
213
- })();
214
- </script>
215
- </body>
216
- </html>`
217
- }
218
-
219
- function escapeHtml(str: string): string {
220
- return str
221
- .replace(/&/g, '&amp;')
222
- .replace(/</g, '&lt;')
223
- .replace(/>/g, '&gt;')
224
- .replace(/"/g, '&quot;')
225
- .replace(/'/g, '&#39;')
226
- }
1
+ /**
2
+ * HTML Renderer
3
+ * Generates standalone interactive HTML pages from NetworkGraph
4
+ */
5
+
6
+ import type { HierarchicalNetworkGraph, LayoutResult, NetworkGraph } from '@shumoku/core'
7
+ import { SVGRenderer } from '../svg.js'
8
+ import type { HTMLRendererOptions } from '../types.js'
9
+ import {
10
+ generateNavigationToolbar,
11
+ getNavigationScript,
12
+ getNavigationStyles,
13
+ type NavigationState,
14
+ type SheetInfo,
15
+ } from './navigation.js'
16
+
17
+ export type { InteractiveInstance, InteractiveOptions } from '../types.js'
18
+ // Re-export navigation types
19
+ export type { NavigationState, SheetInfo } from './navigation.js'
20
+ // Re-export runtime for direct usage
21
+ export { initInteractive } from './runtime.js'
22
+
23
+ // IIFE content - will be set by consumer
24
+ let INTERACTIVE_IIFE = ''
25
+
26
+ /**
27
+ * Set the IIFE content for standalone HTML pages
28
+ */
29
+ export function setIIFE(iife: string): void {
30
+ INTERACTIVE_IIFE = iife
31
+ }
32
+
33
+ /**
34
+ * Get the current IIFE content
35
+ */
36
+ export function getIIFE(): string {
37
+ return INTERACTIVE_IIFE
38
+ }
39
+
40
+ export interface RenderOptions extends HTMLRendererOptions {
41
+ /**
42
+ * Enable hierarchical navigation UI
43
+ */
44
+ hierarchical?: boolean
45
+
46
+ /**
47
+ * Current sheet ID for hierarchical rendering
48
+ */
49
+ currentSheet?: string
50
+
51
+ /**
52
+ * Navigation state for hierarchical diagrams
53
+ */
54
+ navigation?: NavigationState
55
+ }
56
+
57
+ const DEFAULT_OPTIONS = {
58
+ branding: true,
59
+ toolbar: true,
60
+ hierarchical: false,
61
+ }
62
+
63
+ /**
64
+ * Render a complete standalone HTML page from NetworkGraph
65
+ */
66
+ export function render(graph: NetworkGraph, layout: LayoutResult, options?: RenderOptions): string {
67
+ const opts = { ...DEFAULT_OPTIONS, ...options }
68
+
69
+ // Auto-detect hierarchical mode if not explicitly set
70
+ if (options?.hierarchical === undefined) {
71
+ opts.hierarchical = hasHierarchicalContent(graph)
72
+ }
73
+
74
+ const svgRenderer = new SVGRenderer({ renderMode: 'interactive' })
75
+ const svg = svgRenderer.render(graph, layout)
76
+ const title = options?.title || graph.name || 'Network Diagram'
77
+
78
+ // Build navigation state if hierarchical
79
+ let navigation: NavigationState | undefined = options?.navigation
80
+ if (!navigation && opts.hierarchical && isHierarchicalGraph(graph)) {
81
+ navigation = buildNavigationState(graph as HierarchicalNetworkGraph, opts.currentSheet)
82
+ }
83
+
84
+ return generateHtml(svg, title, { ...opts, navigation } as Required<RenderOptions>)
85
+ }
86
+
87
+ /**
88
+ * Sheet data for hierarchical rendering
89
+ */
90
+ export interface SheetData {
91
+ graph: NetworkGraph
92
+ layout: LayoutResult
93
+ }
94
+
95
+ /**
96
+ * Render a hierarchical HTML page with multiple embedded sheets
97
+ */
98
+ export function renderHierarchical(
99
+ sheets: Map<string, SheetData>,
100
+ options?: RenderOptions,
101
+ ): string {
102
+ const opts = { ...DEFAULT_OPTIONS, ...options, hierarchical: true }
103
+
104
+ const sheetSvgs = new Map<string, string>()
105
+ const sheetInfos = new Map<string, SheetInfo>()
106
+ const rootSheet = sheets.get('root')
107
+
108
+ // Render child sheets for navigation (detail view when clicking subgraphs)
109
+ for (const [sheetId, data] of sheets) {
110
+ if (sheetId === 'root') continue // Skip root for now
111
+
112
+ // Render child sheet
113
+ const svgRenderer = new SVGRenderer({ renderMode: 'interactive', sheetId })
114
+ const svg = svgRenderer.render(data.graph, data.layout)
115
+ sheetSvgs.set(sheetId, svg)
116
+ sheetInfos.set(sheetId, {
117
+ id: sheetId,
118
+ label: data.graph.name || sheetId,
119
+ parentId: 'root',
120
+ })
121
+ }
122
+
123
+ // Render root sheet (ELK handles hierarchical layout natively, no embedding needed)
124
+ if (rootSheet) {
125
+ const rootRenderer = new SVGRenderer({
126
+ renderMode: 'interactive',
127
+ sheetId: 'root',
128
+ })
129
+ const rootSvg = rootRenderer.render(rootSheet.graph, rootSheet.layout)
130
+ sheetSvgs.set('root', rootSvg)
131
+ sheetInfos.set('root', {
132
+ id: 'root',
133
+ label: rootSheet.graph.name || 'root',
134
+ parentId: undefined,
135
+ })
136
+ }
137
+
138
+ // Get title from root sheet
139
+ const title = options?.title || rootSheet?.graph.name || 'Network Diagram'
140
+
141
+ // Build navigation state
142
+ const navigation: NavigationState = {
143
+ currentSheet: 'root',
144
+ breadcrumb: ['root'],
145
+ sheets: sheetInfos,
146
+ }
147
+
148
+ return generateHierarchicalHtml(sheetSvgs, title, { ...opts, navigation } as Required<RenderOptions>)
149
+ }
150
+
151
+ /**
152
+ * Check if graph is a hierarchical graph
153
+ */
154
+ function isHierarchicalGraph(graph: NetworkGraph): graph is HierarchicalNetworkGraph {
155
+ return 'sheets' in graph || 'breadcrumb' in graph
156
+ }
157
+
158
+ /**
159
+ * Check if graph has hierarchical content (subgraphs with file/pins)
160
+ */
161
+ function hasHierarchicalContent(graph: NetworkGraph): boolean {
162
+ if (isHierarchicalGraph(graph)) return true
163
+ if (!graph.subgraphs) return false
164
+ return graph.subgraphs.some((sg) => sg.file || (sg.pins && sg.pins.length > 0))
165
+ }
166
+
167
+ /**
168
+ * Build navigation state from hierarchical graph
169
+ */
170
+ function buildNavigationState(
171
+ graph: HierarchicalNetworkGraph,
172
+ currentSheet?: string,
173
+ ): NavigationState {
174
+ const sheets = new Map<string, SheetInfo>()
175
+
176
+ // Add root
177
+ sheets.set('root', {
178
+ id: 'root',
179
+ label: graph.name || 'Overview',
180
+ })
181
+
182
+ // Add sheets from subgraphs
183
+ if (graph.subgraphs) {
184
+ for (const subgraph of graph.subgraphs) {
185
+ if (subgraph.file) {
186
+ sheets.set(subgraph.id, {
187
+ id: subgraph.id,
188
+ label: subgraph.label,
189
+ parentId: 'root',
190
+ })
191
+ }
192
+ }
193
+ }
194
+
195
+ // Add nested sheets
196
+ if (graph.sheets) {
197
+ for (const [id, sheet] of graph.sheets) {
198
+ if (!sheets.has(id)) {
199
+ sheets.set(id, {
200
+ id,
201
+ label: sheet.name || id,
202
+ parentId: graph.parentSheet,
203
+ })
204
+ }
205
+ }
206
+ }
207
+
208
+ // Build breadcrumb
209
+ const breadcrumb = graph.breadcrumb || ['root']
210
+ if (currentSheet && !breadcrumb.includes(currentSheet)) {
211
+ breadcrumb.push(currentSheet)
212
+ }
213
+
214
+ return {
215
+ currentSheet,
216
+ breadcrumb,
217
+ sheets,
218
+ }
219
+ }
220
+
221
+ function generateHtml(svg: string, title: string, options: Required<RenderOptions>): string {
222
+ const brandingHtml = options.branding
223
+ ? `<a class="branding" href="https://shumoku.packof.me" target="_blank" rel="noopener">
224
+ <svg class="branding-icon" viewBox="0 0 1024 1024" fill="none"><rect x="64" y="64" width="896" height="896" rx="200" fill="#7FE4C1"/><g transform="translate(90,40) scale(1.25)"><path fill="#1F2328" d="M380 340H450V505H700V555H510V645H450V645H380Z"/></g></svg>
225
+ <span>Made with Shumoku</span>
226
+ </a>`
227
+ : ''
228
+
229
+ const toolbarHtml = options.toolbar
230
+ ? `<div class="toolbar">
231
+ <span class="toolbar-title">${escapeHtml(title)}</span>
232
+ <div class="toolbar-buttons">
233
+ <button id="btn-out" title="Zoom Out"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M20 12H4"/></svg></button>
234
+ <span class="zoom-text" id="zoom">100%</span>
235
+ <button id="btn-in" title="Zoom In"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg></button>
236
+ <button id="btn-fit" title="Fit"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/></svg></button>
237
+ <button id="btn-reset" title="Reset"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
238
+ </div>
239
+ </div>`
240
+ : ''
241
+
242
+ // Navigation toolbar for hierarchical diagrams
243
+ const navToolbarHtml =
244
+ options.hierarchical && options.navigation ? generateNavigationToolbar(options.navigation) : ''
245
+
246
+ // Navigation styles
247
+ const navStyles = options.hierarchical ? getNavigationStyles() : ''
248
+
249
+ // Navigation scripts
250
+ const navScript = options.hierarchical ? getNavigationScript() : ''
251
+
252
+ // Calculate container height based on toolbar presence
253
+ const headerHeight = options.toolbar ? 45 : 0
254
+ const navHeight = options.hierarchical && options.navigation ? 60 : 0
255
+ const totalHeaderHeight = headerHeight + navHeight
256
+ const containerHeight = totalHeaderHeight > 0 ? `calc(100vh - ${totalHeaderHeight}px)` : '100vh'
257
+
258
+ return `<!DOCTYPE html>
259
+ <html>
260
+ <head>
261
+ <meta charset="UTF-8">
262
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
263
+ <title>${escapeHtml(title)}</title>
264
+ <style>
265
+ * { margin: 0; padding: 0; box-sizing: border-box; }
266
+ body { background: #f5f5f5; min-height: 100vh; font-family: system-ui, -apple-system, sans-serif; }
267
+ .toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: white; border-bottom: 1px solid #e5e5e5; }
268
+ .toolbar-title { font-size: 14px; color: #666; }
269
+ .toolbar-buttons { display: flex; gap: 4px; align-items: center; }
270
+ .toolbar button { padding: 6px; border: none; background: none; cursor: pointer; border-radius: 4px; color: #666; }
271
+ .toolbar button:hover { background: #f0f0f0; }
272
+ .zoom-text { min-width: 50px; text-align: center; font-size: 13px; color: #666; }
273
+ .container { position: relative; width: 100%; height: ${containerHeight}; overflow: hidden; cursor: grab; background: repeating-conic-gradient(#f8f8f8 0% 25%, transparent 0% 50%) 50% / 20px 20px; }
274
+ .container.dragging { cursor: grabbing; }
275
+ .container > svg { width: 100%; height: 100%; }
276
+ .branding { position: absolute; bottom: 16px; right: 16px; display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; font-size: 13px; font-family: system-ui, sans-serif; color: #555; text-decoration: none; transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }
277
+ .branding:hover { color: #222; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
278
+ .branding-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
279
+ /* SVG interactive styles */
280
+ .node { cursor: pointer; }
281
+ .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
282
+ .port { cursor: pointer; }
283
+ .link-hit-area { cursor: pointer; }
284
+ /* Subgraph click for hierarchical navigation */
285
+ .subgraph[data-has-sheet] { cursor: pointer; }
286
+ .subgraph[data-has-sheet]:hover > rect { filter: brightness(0.95); }
287
+ ${navStyles}
288
+ </style>
289
+ </head>
290
+ <body>
291
+ ${toolbarHtml}
292
+ ${navToolbarHtml}
293
+ <div class="container" id="container">
294
+ ${svg}
295
+ ${brandingHtml}
296
+ </div>
297
+ <script>${INTERACTIVE_IIFE}</script>
298
+ <script>
299
+ (function() {
300
+ var svg = document.querySelector('#container > svg');
301
+ var container = document.getElementById('container');
302
+ if (!svg || !container) { console.error('SVG or container not found'); return; }
303
+
304
+ var vb = { x: 0, y: 0, w: 0, h: 0 };
305
+ var origVb = { x: 0, y: 0, w: 0, h: 0 };
306
+ var drag = { active: false, x: 0, y: 0, vx: 0, vy: 0 };
307
+
308
+ function init() {
309
+ var w = parseFloat(svg.getAttribute('width')) || 800;
310
+ var h = parseFloat(svg.getAttribute('height')) || 600;
311
+ var existing = svg.getAttribute('viewBox');
312
+ if (existing) {
313
+ var p = existing.split(/\\s+|,/).map(Number);
314
+ origVb = { x: p[0] || 0, y: p[1] || 0, w: p[2] || w, h: p[3] || h };
315
+ } else {
316
+ origVb = { x: 0, y: 0, w: w, h: h };
317
+ }
318
+ svg.removeAttribute('width');
319
+ svg.removeAttribute('height');
320
+ svg.style.width = '100%';
321
+ svg.style.height = '100%';
322
+ fitView();
323
+
324
+ if (window.ShumokuInteractive) {
325
+ window.ShumokuInteractive.initInteractive({ target: svg, modal: { enabled: true }, tooltip: { enabled: true }, panZoom: { enabled: false } });
326
+ }
327
+ }
328
+
329
+ function updateViewBox() {
330
+ svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h);
331
+ var zoomEl = document.getElementById('zoom');
332
+ if (zoomEl) zoomEl.textContent = Math.round(origVb.w / vb.w * 100) + '%';
333
+ }
334
+
335
+ function fitView() {
336
+ var cw = container.clientWidth || 800;
337
+ var ch = container.clientHeight || 600;
338
+ var scale = Math.min(cw / origVb.w, ch / origVb.h) * 0.9;
339
+ vb.w = cw / scale;
340
+ vb.h = ch / scale;
341
+ vb.x = origVb.x + (origVb.w - vb.w) / 2;
342
+ vb.y = origVb.y + (origVb.h - vb.h) / 2;
343
+ updateViewBox();
344
+ }
345
+
346
+ function resetView() {
347
+ vb.x = origVb.x; vb.y = origVb.y; vb.w = origVb.w; vb.h = origVb.h;
348
+ updateViewBox();
349
+ }
350
+
351
+ function zoom(f) {
352
+ var cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
353
+ var nw = vb.w / f, nh = vb.h / f;
354
+ var scale = origVb.w / nw;
355
+ if (scale < 0.1 || scale > 10) return;
356
+ vb.w = nw; vb.h = nh; vb.x = cx - nw / 2; vb.y = cy - nh / 2;
357
+ updateViewBox();
358
+ }
359
+
360
+ var btnIn = document.getElementById('btn-in');
361
+ var btnOut = document.getElementById('btn-out');
362
+ var btnFit = document.getElementById('btn-fit');
363
+ var btnReset = document.getElementById('btn-reset');
364
+ if (btnIn) btnIn.addEventListener('click', function() { zoom(1.2); });
365
+ if (btnOut) btnOut.addEventListener('click', function() { zoom(1/1.2); });
366
+ if (btnFit) btnFit.addEventListener('click', fitView);
367
+ if (btnReset) btnReset.addEventListener('click', resetView);
368
+
369
+ container.addEventListener('wheel', function(e) {
370
+ e.preventDefault();
371
+ var rect = container.getBoundingClientRect();
372
+ var mx = (e.clientX - rect.left) / rect.width;
373
+ var my = (e.clientY - rect.top) / rect.height;
374
+ var px = vb.x + vb.w * mx, py = vb.y + vb.h * my;
375
+ var f = e.deltaY > 0 ? 1/1.2 : 1.2;
376
+ var nw = vb.w / f, nh = vb.h / f;
377
+ var scale = origVb.w / nw;
378
+ if (scale < 0.1 || scale > 10) return;
379
+ vb.w = nw; vb.h = nh; vb.x = px - nw * mx; vb.y = py - nh * my;
380
+ updateViewBox();
381
+ }, { passive: false });
382
+
383
+ container.addEventListener('mousedown', function(e) {
384
+ if (e.button === 0) {
385
+ drag = { active: true, x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y };
386
+ container.classList.add('dragging');
387
+ }
388
+ });
389
+
390
+ document.addEventListener('mousemove', function(e) {
391
+ if (!drag.active) return;
392
+ var sx = vb.w / container.clientWidth;
393
+ var sy = vb.h / container.clientHeight;
394
+ vb.x = drag.vx - (e.clientX - drag.x) * sx;
395
+ vb.y = drag.vy - (e.clientY - drag.y) * sy;
396
+ updateViewBox();
397
+ });
398
+
399
+ document.addEventListener('mouseup', function() {
400
+ drag.active = false;
401
+ container.classList.remove('dragging');
402
+ });
403
+
404
+ // Touch events for pan/zoom
405
+ var pinch = null;
406
+ var touch1 = null;
407
+ var hasMoved = false;
408
+ var DRAG_THRESHOLD = 8;
409
+
410
+ function getTouchDist(t) {
411
+ if (t.length < 2) return 0;
412
+ return Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY);
413
+ }
414
+
415
+ function getTouchCenter(t) {
416
+ return { x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 };
417
+ }
418
+
419
+ container.addEventListener('touchstart', function(e) {
420
+ // Skip if touching branding link
421
+ if (e.target.closest && e.target.closest('.branding')) return;
422
+
423
+ if (e.touches.length === 1) {
424
+ // Single finger - potential pan or tap
425
+ touch1 = { x: e.touches[0].clientX, y: e.touches[0].clientY, vx: vb.x, vy: vb.y };
426
+ hasMoved = false;
427
+ } else if (e.touches.length >= 2) {
428
+ // Two fingers - pinch zoom
429
+ e.preventDefault();
430
+ touch1 = null;
431
+ hasMoved = true;
432
+ var dist = getTouchDist(e.touches);
433
+ var center = getTouchCenter(e.touches);
434
+ var rect = container.getBoundingClientRect();
435
+ pinch = {
436
+ dist: dist,
437
+ vb: { x: vb.x, y: vb.y, w: vb.w, h: vb.h },
438
+ cx: vb.x + vb.w * ((center.x - rect.left) / rect.width),
439
+ cy: vb.y + vb.h * ((center.y - rect.top) / rect.height),
440
+ lastCenter: center
441
+ };
442
+ }
443
+ }, { passive: false });
444
+
445
+ container.addEventListener('touchmove', function(e) {
446
+ // Skip if touching branding link
447
+ if (e.target.closest && e.target.closest('.branding')) return;
448
+
449
+ if (e.touches.length === 1 && touch1) {
450
+ var dx = e.touches[0].clientX - touch1.x;
451
+ var dy = e.touches[0].clientY - touch1.y;
452
+
453
+ // Check if moved beyond threshold
454
+ if (!hasMoved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
455
+ hasMoved = true;
456
+ }
457
+
458
+ if (hasMoved) {
459
+ e.preventDefault();
460
+ var sx = vb.w / container.clientWidth;
461
+ var sy = vb.h / container.clientHeight;
462
+ vb.x = touch1.vx - dx * sx;
463
+ vb.y = touch1.vy - dy * sy;
464
+ updateViewBox();
465
+ }
466
+ } else if (e.touches.length >= 2 && pinch) {
467
+ e.preventDefault();
468
+ var dist = getTouchDist(e.touches);
469
+ var center = getTouchCenter(e.touches);
470
+ if (dist === 0 || pinch.dist === 0) return;
471
+
472
+ var scale = dist / pinch.dist;
473
+ var nw = pinch.vb.w / scale;
474
+ var nh = pinch.vb.h / scale;
475
+ var newScale = origVb.w / nw;
476
+ if (newScale < 0.1 || newScale > 10) return;
477
+
478
+ var rect = container.getBoundingClientRect();
479
+ var sx = nw / rect.width;
480
+ var sy = nh / rect.height;
481
+ var panX = (center.x - pinch.lastCenter.x) * sx;
482
+ var panY = (center.y - pinch.lastCenter.y) * sy;
483
+
484
+ var mx = (center.x - rect.left) / rect.width;
485
+ var my = (center.y - rect.top) / rect.height;
486
+ vb.x = pinch.cx - nw * mx - panX;
487
+ vb.y = pinch.cy - nh * my - panY;
488
+ vb.w = nw;
489
+ vb.h = nh;
490
+ updateViewBox();
491
+ }
492
+ }, { passive: false });
493
+
494
+ container.addEventListener('touchend', function(e) {
495
+ if (e.touches.length === 0) {
496
+ touch1 = null;
497
+ pinch = null;
498
+ hasMoved = false;
499
+ } else if (e.touches.length === 1) {
500
+ pinch = null;
501
+ touch1 = { x: e.touches[0].clientX, y: e.touches[0].clientY, vx: vb.x, vy: vb.y };
502
+ hasMoved = true; // Already moving
503
+ }
504
+ });
505
+
506
+ container.addEventListener('touchcancel', function() {
507
+ touch1 = null;
508
+ pinch = null;
509
+ hasMoved = false;
510
+ });
511
+
512
+ // Listen for hierarchical navigation events
513
+ document.addEventListener('shumoku:navigate', function(e) {
514
+ var sheetId = e.detail && e.detail.sheetId;
515
+ if (sheetId) {
516
+ console.log('[Shumoku] Navigate to sheet:', sheetId);
517
+ alert('Navigate to: ' + sheetId + '\\n\\nThis sheet would show the detailed view of this subgraph.');
518
+ }
519
+ });
520
+
521
+ init();
522
+ })();
523
+ </script>
524
+ <script>${navScript}</script>
525
+ </body>
526
+ </html>`
527
+ }
528
+
529
+ function escapeHtml(str: string): string {
530
+ return str
531
+ .replace(/&/g, '&amp;')
532
+ .replace(/</g, '&lt;')
533
+ .replace(/>/g, '&gt;')
534
+ .replace(/"/g, '&quot;')
535
+ .replace(/'/g, '&#39;')
536
+ }
537
+
538
+ /**
539
+ * Generate HTML with multiple embedded sheets for hierarchical navigation
540
+ */
541
+ function generateHierarchicalHtml(
542
+ sheetSvgs: Map<string, string>,
543
+ title: string,
544
+ options: Required<RenderOptions>,
545
+ ): string {
546
+ const brandingHtml = options.branding
547
+ ? `<a class="branding" href="https://shumoku.packof.me" target="_blank" rel="noopener">
548
+ <svg class="branding-icon" viewBox="0 0 1024 1024" fill="none"><rect x="64" y="64" width="896" height="896" rx="200" fill="#7FE4C1"/><g transform="translate(90,40) scale(1.25)"><path fill="#1F2328" d="M380 340H450V505H700V555H510V645H450V645H380Z"/></g></svg>
549
+ <span>Made with Shumoku</span>
550
+ </a>`
551
+ : ''
552
+
553
+ const toolbarHtml = options.toolbar
554
+ ? `<div class="toolbar">
555
+ <span class="toolbar-title" id="sheet-title">${escapeHtml(title)}</span>
556
+ <div class="toolbar-buttons">
557
+ <button id="btn-out" title="Zoom Out"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M20 12H4"/></svg></button>
558
+ <span class="zoom-text" id="zoom">100%</span>
559
+ <button id="btn-in" title="Zoom In"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg></button>
560
+ <button id="btn-fit" title="Fit"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/></svg></button>
561
+ <button id="btn-reset" title="Reset"><svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg></button>
562
+ </div>
563
+ </div>`
564
+ : ''
565
+
566
+ // Build sheet containers
567
+ const sheetContainers: string[] = []
568
+ for (const [sheetId, svg] of sheetSvgs) {
569
+ const isRoot = sheetId === 'root'
570
+ const display = isRoot ? 'block' : 'none'
571
+ sheetContainers.push(
572
+ `<div class="sheet-container" data-sheet-id="${escapeHtml(sheetId)}" style="display: ${display};">
573
+ ${svg}
574
+ </div>`,
575
+ )
576
+ }
577
+
578
+ // Build sheet info JSON for JavaScript
579
+ const sheetInfoJson: Record<string, { label: string; parentId?: string }> = {}
580
+ for (const [id, info] of options.navigation?.sheets || []) {
581
+ sheetInfoJson[id] = { label: info.label, parentId: info.parentId }
582
+ }
583
+
584
+ const headerHeight = options.toolbar ? 45 : 0
585
+ const containerHeight = headerHeight > 0 ? `calc(100vh - ${headerHeight}px)` : '100vh'
586
+
587
+ return `<!DOCTYPE html>
588
+ <html>
589
+ <head>
590
+ <meta charset="UTF-8">
591
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
592
+ <title>${escapeHtml(title)}</title>
593
+ <style>
594
+ * { margin: 0; padding: 0; box-sizing: border-box; }
595
+ body { background: #f5f5f5; min-height: 100vh; font-family: system-ui, -apple-system, sans-serif; }
596
+ .toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: white; border-bottom: 1px solid #e5e5e5; }
597
+ .toolbar-title { font-size: 14px; color: #666; display: flex; align-items: center; gap: 8px; }
598
+ .toolbar-buttons { display: flex; gap: 4px; align-items: center; }
599
+ .toolbar button { padding: 6px; border: none; background: none; cursor: pointer; border-radius: 4px; color: #666; }
600
+ .toolbar button:hover { background: #f0f0f0; }
601
+ .zoom-text { min-width: 50px; text-align: center; font-size: 13px; color: #666; }
602
+ .back-btn { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; font-size: 13px; color: #555; }
603
+ .back-btn:hover { background: #f5f5f5; }
604
+ .back-btn svg { width: 14px; height: 14px; }
605
+ .container { position: relative; width: 100%; height: ${containerHeight}; overflow: hidden; cursor: grab; background: repeating-conic-gradient(#f8f8f8 0% 25%, transparent 0% 50%) 50% / 20px 20px; }
606
+ .container.dragging { cursor: grabbing; }
607
+ .sheet-container { width: 100%; height: 100%; }
608
+ .sheet-container > svg { width: 100%; height: 100%; }
609
+ .branding { position: absolute; bottom: 16px; right: 16px; display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.1); border-radius: 8px; font-size: 13px; font-family: system-ui, sans-serif; color: #555; text-decoration: none; transition: all 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 100; }
610
+ .branding:hover { color: #222; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
611
+ .branding-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; }
612
+ .node { cursor: pointer; }
613
+ .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
614
+ .port { cursor: pointer; }
615
+ .link-hit-area { cursor: pointer; }
616
+ .subgraph[data-has-sheet] { cursor: pointer; }
617
+ .subgraph[data-has-sheet]:hover > rect { filter: brightness(0.95); }
618
+ </style>
619
+ </head>
620
+ <body>
621
+ ${toolbarHtml}
622
+ <div class="container" id="container">
623
+ ${sheetContainers.join('\n ')}
624
+ ${brandingHtml}
625
+ </div>
626
+ <script>${INTERACTIVE_IIFE}</script>
627
+ <script>
628
+ (function() {
629
+ var sheetInfo = ${JSON.stringify(sheetInfoJson)};
630
+ var currentSheet = 'root';
631
+ var breadcrumb = ['root'];
632
+ var sheetViewBoxes = {};
633
+ var container = document.getElementById('container');
634
+
635
+ function getActiveSheet() {
636
+ return container.querySelector('.sheet-container[data-sheet-id="' + currentSheet + '"]');
637
+ }
638
+
639
+ function getActiveSvg() {
640
+ var sheet = getActiveSheet();
641
+ return sheet ? sheet.querySelector('svg') : null;
642
+ }
643
+
644
+ function initSheet(sheetId) {
645
+ var sheet = container.querySelector('.sheet-container[data-sheet-id="' + sheetId + '"]');
646
+ if (!sheet) return;
647
+ var svg = sheet.querySelector('svg');
648
+ if (!svg) return;
649
+
650
+ var w = parseFloat(svg.getAttribute('width')) || 800;
651
+ var h = parseFloat(svg.getAttribute('height')) || 600;
652
+ var existing = svg.getAttribute('viewBox');
653
+ var vb;
654
+ if (existing) {
655
+ var p = existing.split(/\\s+|,/).map(Number);
656
+ vb = { x: p[0] || 0, y: p[1] || 0, w: p[2] || w, h: p[3] || h, origX: p[0] || 0, origY: p[1] || 0, origW: p[2] || w, origH: p[3] || h };
657
+ } else {
658
+ vb = { x: 0, y: 0, w: w, h: h, origX: 0, origY: 0, origW: w, origH: h };
659
+ }
660
+ sheetViewBoxes[sheetId] = vb;
661
+
662
+ svg.removeAttribute('width');
663
+ svg.removeAttribute('height');
664
+ svg.style.width = '100%';
665
+ svg.style.height = '100%';
666
+
667
+ // Fit view
668
+ var cw = container.clientWidth || 800;
669
+ var ch = container.clientHeight || 600;
670
+ var scale = Math.min(cw / vb.origW, ch / vb.origH) * 0.9;
671
+ vb.w = cw / scale;
672
+ vb.h = ch / scale;
673
+ vb.x = vb.origX + (vb.origW - vb.w) / 2;
674
+ vb.y = vb.origY + (vb.origH - vb.h) / 2;
675
+ svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h);
676
+
677
+ if (window.ShumokuInteractive) {
678
+ window.ShumokuInteractive.initInteractive({ target: svg, modal: { enabled: true }, tooltip: { enabled: true }, panZoom: { enabled: false } });
679
+ }
680
+ }
681
+
682
+ function updateViewBox() {
683
+ var svg = getActiveSvg();
684
+ var vb = sheetViewBoxes[currentSheet];
685
+ if (!svg || !vb) return;
686
+ svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h);
687
+ var zoomEl = document.getElementById('zoom');
688
+ if (zoomEl) zoomEl.textContent = Math.round(vb.origW / vb.w * 100) + '%';
689
+ }
690
+
691
+ function updateTitle() {
692
+ var titleEl = document.getElementById('sheet-title');
693
+ if (!titleEl) return;
694
+ var info = sheetInfo[currentSheet];
695
+ var label = info ? info.label : currentSheet;
696
+
697
+ if (currentSheet === 'root') {
698
+ titleEl.innerHTML = label;
699
+ } else {
700
+ titleEl.innerHTML = '<button class="back-btn" id="back-btn"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg></button>' + label;
701
+ document.getElementById('back-btn').addEventListener('click', function() {
702
+ navigateToSheet('root');
703
+ });
704
+ }
705
+ }
706
+
707
+ function navigateToSheet(sheetId) {
708
+ if (sheetId === currentSheet) return;
709
+ if (!container.querySelector('.sheet-container[data-sheet-id="' + sheetId + '"]')) {
710
+ console.warn('[Shumoku] Sheet not found:', sheetId);
711
+ return;
712
+ }
713
+
714
+ // Hide current
715
+ var current = getActiveSheet();
716
+ if (current) current.style.display = 'none';
717
+
718
+ // Show new
719
+ currentSheet = sheetId;
720
+ var newSheet = getActiveSheet();
721
+ if (newSheet) {
722
+ newSheet.style.display = 'block';
723
+ if (!sheetViewBoxes[sheetId]) {
724
+ initSheet(sheetId);
725
+ }
726
+ }
727
+
728
+ // Update breadcrumb
729
+ if (sheetId === 'root') {
730
+ breadcrumb = ['root'];
731
+ } else {
732
+ breadcrumb = ['root', sheetId];
733
+ }
734
+
735
+ updateTitle();
736
+ updateViewBox();
737
+ }
738
+
739
+ // Zoom functions
740
+ function zoom(f) {
741
+ var vb = sheetViewBoxes[currentSheet];
742
+ if (!vb) return;
743
+ var cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2;
744
+ var nw = vb.w / f, nh = vb.h / f;
745
+ var scale = vb.origW / nw;
746
+ if (scale < 0.1 || scale > 10) return;
747
+ vb.w = nw; vb.h = nh; vb.x = cx - nw / 2; vb.y = cy - nh / 2;
748
+ updateViewBox();
749
+ }
750
+
751
+ function fitView() {
752
+ var vb = sheetViewBoxes[currentSheet];
753
+ if (!vb) return;
754
+ var cw = container.clientWidth || 800;
755
+ var ch = container.clientHeight || 600;
756
+ var scale = Math.min(cw / vb.origW, ch / vb.origH) * 0.9;
757
+ vb.w = cw / scale;
758
+ vb.h = ch / scale;
759
+ vb.x = vb.origX + (vb.origW - vb.w) / 2;
760
+ vb.y = vb.origY + (vb.origH - vb.h) / 2;
761
+ updateViewBox();
762
+ }
763
+
764
+ function resetView() {
765
+ var vb = sheetViewBoxes[currentSheet];
766
+ if (!vb) return;
767
+ vb.x = vb.origX; vb.y = vb.origY; vb.w = vb.origW; vb.h = vb.origH;
768
+ updateViewBox();
769
+ }
770
+
771
+ // Toolbar buttons
772
+ var btnIn = document.getElementById('btn-in');
773
+ var btnOut = document.getElementById('btn-out');
774
+ var btnFit = document.getElementById('btn-fit');
775
+ var btnReset = document.getElementById('btn-reset');
776
+ if (btnIn) btnIn.addEventListener('click', function() { zoom(1.2); });
777
+ if (btnOut) btnOut.addEventListener('click', function() { zoom(1/1.2); });
778
+ if (btnFit) btnFit.addEventListener('click', fitView);
779
+ if (btnReset) btnReset.addEventListener('click', resetView);
780
+
781
+ // Mouse drag
782
+ var drag = { active: false, x: 0, y: 0 };
783
+
784
+ container.addEventListener('mousedown', function(e) {
785
+ if (e.button === 0) {
786
+ var vb = sheetViewBoxes[currentSheet];
787
+ if (!vb) return;
788
+ drag = { active: true, x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y };
789
+ container.classList.add('dragging');
790
+ }
791
+ });
792
+
793
+ document.addEventListener('mousemove', function(e) {
794
+ if (!drag.active) return;
795
+ var vb = sheetViewBoxes[currentSheet];
796
+ if (!vb) return;
797
+ var sx = vb.w / container.clientWidth;
798
+ var sy = vb.h / container.clientHeight;
799
+ vb.x = drag.vx - (e.clientX - drag.x) * sx;
800
+ vb.y = drag.vy - (e.clientY - drag.y) * sy;
801
+ updateViewBox();
802
+ });
803
+
804
+ document.addEventListener('mouseup', function() {
805
+ drag.active = false;
806
+ container.classList.remove('dragging');
807
+ });
808
+
809
+ // Wheel zoom
810
+ container.addEventListener('wheel', function(e) {
811
+ e.preventDefault();
812
+ var vb = sheetViewBoxes[currentSheet];
813
+ if (!vb) return;
814
+ var rect = container.getBoundingClientRect();
815
+ var mx = (e.clientX - rect.left) / rect.width;
816
+ var my = (e.clientY - rect.top) / rect.height;
817
+ var px = vb.x + vb.w * mx, py = vb.y + vb.h * my;
818
+ var f = e.deltaY > 0 ? 1/1.2 : 1.2;
819
+ var nw = vb.w / f, nh = vb.h / f;
820
+ var scale = vb.origW / nw;
821
+ if (scale < 0.1 || scale > 10) return;
822
+ vb.w = nw; vb.h = nh; vb.x = px - nw * mx; vb.y = py - nh * my;
823
+ updateViewBox();
824
+ }, { passive: false });
825
+
826
+ // Navigation event listener
827
+ document.addEventListener('shumoku:navigate', function(e) {
828
+ var sheetId = e.detail && e.detail.sheetId;
829
+ if (sheetId) {
830
+ navigateToSheet(sheetId);
831
+ }
832
+ });
833
+
834
+ // Touch support (simplified)
835
+ var touch1 = null;
836
+ var hasMoved = false;
837
+
838
+ container.addEventListener('touchstart', function(e) {
839
+ if (e.target.closest && e.target.closest('.branding')) return;
840
+ if (e.touches.length === 1) {
841
+ var vb = sheetViewBoxes[currentSheet];
842
+ if (vb) {
843
+ touch1 = { x: e.touches[0].clientX, y: e.touches[0].clientY, vx: vb.x, vy: vb.y };
844
+ hasMoved = false;
845
+ }
846
+ }
847
+ }, { passive: false });
848
+
849
+ container.addEventListener('touchmove', function(e) {
850
+ if (e.target.closest && e.target.closest('.branding')) return;
851
+ if (e.touches.length === 1 && touch1) {
852
+ var vb = sheetViewBoxes[currentSheet];
853
+ if (!vb) return;
854
+ var dx = e.touches[0].clientX - touch1.x;
855
+ var dy = e.touches[0].clientY - touch1.y;
856
+ if (!hasMoved && Math.hypot(dx, dy) > 8) hasMoved = true;
857
+ if (hasMoved) {
858
+ e.preventDefault();
859
+ var sx = vb.w / container.clientWidth;
860
+ var sy = vb.h / container.clientHeight;
861
+ vb.x = touch1.vx - dx * sx;
862
+ vb.y = touch1.vy - dy * sy;
863
+ updateViewBox();
864
+ }
865
+ }
866
+ }, { passive: false });
867
+
868
+ container.addEventListener('touchend', function() {
869
+ touch1 = null;
870
+ hasMoved = false;
871
+ });
872
+
873
+ // Initialize root sheet
874
+ initSheet('root');
875
+ updateTitle();
876
+ })();
877
+ </script>
878
+ </body>
879
+ </html>`
880
+ }