@shumoku/renderer 0.2.0 → 0.2.3
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.
- package/dist/html/index.js +265 -157
- package/dist/html/index.js.map +1 -1
- package/dist/html/runtime.d.ts +2 -1
- package/dist/html/runtime.d.ts.map +1 -1
- package/dist/html/runtime.js +212 -486
- package/dist/html/runtime.js.map +1 -1
- package/dist/html/spotlight.d.ts +9 -0
- package/dist/html/spotlight.d.ts.map +1 -0
- package/dist/html/spotlight.js +119 -0
- package/dist/html/spotlight.js.map +1 -0
- package/dist/html/tooltip.d.ts +14 -0
- package/dist/html/tooltip.d.ts.map +1 -0
- package/dist/html/tooltip.js +105 -0
- package/dist/html/tooltip.js.map +1 -0
- package/dist/html/viewbox.d.ts +14 -0
- package/dist/html/viewbox.d.ts.map +1 -0
- package/dist/html/viewbox.js +21 -0
- package/dist/html/viewbox.js.map +1 -0
- package/dist/iife-string.js +1 -1
- package/dist/shumoku-interactive.iife.js +23 -20
- package/package.json +1 -1
- package/src/html/index.ts +334 -226
- package/src/html/runtime.ts +370 -654
- package/src/html/spotlight.ts +135 -0
- package/src/html/tooltip.ts +115 -0
- package/src/html/viewbox.ts +28 -0
package/src/html/index.ts
CHANGED
|
@@ -1,226 +1,334 @@
|
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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; font-family: system-ui, -apple-system, sans-serif; }
|
|
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
|
+
// Touch events for pan/zoom
|
|
213
|
+
var pinch = null;
|
|
214
|
+
var touch1 = null;
|
|
215
|
+
var hasMoved = false;
|
|
216
|
+
var DRAG_THRESHOLD = 8;
|
|
217
|
+
|
|
218
|
+
function getTouchDist(t) {
|
|
219
|
+
if (t.length < 2) return 0;
|
|
220
|
+
return Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getTouchCenter(t) {
|
|
224
|
+
return { x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
container.addEventListener('touchstart', function(e) {
|
|
228
|
+
// Skip if touching branding link
|
|
229
|
+
if (e.target.closest && e.target.closest('.branding')) return;
|
|
230
|
+
|
|
231
|
+
if (e.touches.length === 1) {
|
|
232
|
+
// Single finger - potential pan or tap
|
|
233
|
+
touch1 = { x: e.touches[0].clientX, y: e.touches[0].clientY, vx: vb.x, vy: vb.y };
|
|
234
|
+
hasMoved = false;
|
|
235
|
+
} else if (e.touches.length >= 2) {
|
|
236
|
+
// Two fingers - pinch zoom
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
touch1 = null;
|
|
239
|
+
hasMoved = true;
|
|
240
|
+
var dist = getTouchDist(e.touches);
|
|
241
|
+
var center = getTouchCenter(e.touches);
|
|
242
|
+
var rect = container.getBoundingClientRect();
|
|
243
|
+
pinch = {
|
|
244
|
+
dist: dist,
|
|
245
|
+
vb: { x: vb.x, y: vb.y, w: vb.w, h: vb.h },
|
|
246
|
+
cx: vb.x + vb.w * ((center.x - rect.left) / rect.width),
|
|
247
|
+
cy: vb.y + vb.h * ((center.y - rect.top) / rect.height),
|
|
248
|
+
lastCenter: center
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}, { passive: false });
|
|
252
|
+
|
|
253
|
+
container.addEventListener('touchmove', function(e) {
|
|
254
|
+
// Skip if touching branding link
|
|
255
|
+
if (e.target.closest && e.target.closest('.branding')) return;
|
|
256
|
+
|
|
257
|
+
if (e.touches.length === 1 && touch1) {
|
|
258
|
+
var dx = e.touches[0].clientX - touch1.x;
|
|
259
|
+
var dy = e.touches[0].clientY - touch1.y;
|
|
260
|
+
|
|
261
|
+
// Check if moved beyond threshold
|
|
262
|
+
if (!hasMoved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
|
263
|
+
hasMoved = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (hasMoved) {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
var sx = vb.w / container.clientWidth;
|
|
269
|
+
var sy = vb.h / container.clientHeight;
|
|
270
|
+
vb.x = touch1.vx - dx * sx;
|
|
271
|
+
vb.y = touch1.vy - dy * sy;
|
|
272
|
+
updateViewBox();
|
|
273
|
+
}
|
|
274
|
+
} else if (e.touches.length >= 2 && pinch) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
var dist = getTouchDist(e.touches);
|
|
277
|
+
var center = getTouchCenter(e.touches);
|
|
278
|
+
if (dist === 0 || pinch.dist === 0) return;
|
|
279
|
+
|
|
280
|
+
var scale = dist / pinch.dist;
|
|
281
|
+
var nw = pinch.vb.w / scale;
|
|
282
|
+
var nh = pinch.vb.h / scale;
|
|
283
|
+
var newScale = origVb.w / nw;
|
|
284
|
+
if (newScale < 0.1 || newScale > 10) return;
|
|
285
|
+
|
|
286
|
+
var rect = container.getBoundingClientRect();
|
|
287
|
+
var sx = nw / rect.width;
|
|
288
|
+
var sy = nh / rect.height;
|
|
289
|
+
var panX = (center.x - pinch.lastCenter.x) * sx;
|
|
290
|
+
var panY = (center.y - pinch.lastCenter.y) * sy;
|
|
291
|
+
|
|
292
|
+
var mx = (center.x - rect.left) / rect.width;
|
|
293
|
+
var my = (center.y - rect.top) / rect.height;
|
|
294
|
+
vb.x = pinch.cx - nw * mx - panX;
|
|
295
|
+
vb.y = pinch.cy - nh * my - panY;
|
|
296
|
+
vb.w = nw;
|
|
297
|
+
vb.h = nh;
|
|
298
|
+
updateViewBox();
|
|
299
|
+
}
|
|
300
|
+
}, { passive: false });
|
|
301
|
+
|
|
302
|
+
container.addEventListener('touchend', function(e) {
|
|
303
|
+
if (e.touches.length === 0) {
|
|
304
|
+
touch1 = null;
|
|
305
|
+
pinch = null;
|
|
306
|
+
hasMoved = false;
|
|
307
|
+
} else if (e.touches.length === 1) {
|
|
308
|
+
pinch = null;
|
|
309
|
+
touch1 = { x: e.touches[0].clientX, y: e.touches[0].clientY, vx: vb.x, vy: vb.y };
|
|
310
|
+
hasMoved = true; // Already moving
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
container.addEventListener('touchcancel', function() {
|
|
315
|
+
touch1 = null;
|
|
316
|
+
pinch = null;
|
|
317
|
+
hasMoved = false;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
init();
|
|
321
|
+
})();
|
|
322
|
+
</script>
|
|
323
|
+
</body>
|
|
324
|
+
</html>`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function escapeHtml(str: string): string {
|
|
328
|
+
return str
|
|
329
|
+
.replace(/&/g, '&')
|
|
330
|
+
.replace(/</g, '<')
|
|
331
|
+
.replace(/>/g, '>')
|
|
332
|
+
.replace(/"/g, '"')
|
|
333
|
+
.replace(/'/g, ''')
|
|
334
|
+
}
|