@shumoku/renderer 0.2.3 → 0.2.5

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Navigation UI for hierarchical network diagrams
3
+ * Provides breadcrumb, tabs, and back button for sheet navigation
4
+ */
5
+
6
+ /**
7
+ * Navigation state for hierarchical diagrams
8
+ */
9
+ export interface NavigationState {
10
+ /**
11
+ * Current sheet ID (undefined = root)
12
+ */
13
+ currentSheet?: string
14
+
15
+ /**
16
+ * Breadcrumb path from root to current sheet
17
+ */
18
+ breadcrumb: string[]
19
+
20
+ /**
21
+ * Available sheets
22
+ */
23
+ sheets: Map<string, SheetInfo>
24
+ }
25
+
26
+ /**
27
+ * Information about a sheet
28
+ */
29
+ export interface SheetInfo {
30
+ id: string
31
+ label: string
32
+ parentId?: string
33
+ }
34
+
35
+ /**
36
+ * Generate breadcrumb HTML
37
+ */
38
+ export function generateBreadcrumb(state: NavigationState): string {
39
+ const items = state.breadcrumb.map((id, index) => {
40
+ const isLast = index === state.breadcrumb.length - 1
41
+ const label = id === 'root' ? 'Overview' : (state.sheets.get(id)?.label ?? id)
42
+
43
+ if (isLast) {
44
+ return `<span class="breadcrumb-current">${escapeHtml(label)}</span>`
45
+ }
46
+
47
+ return `<button class="breadcrumb-item" data-sheet="${escapeHtml(id)}">${escapeHtml(label)}</button>`
48
+ })
49
+
50
+ return `<nav class="breadcrumb">${items.join('<span class="breadcrumb-sep">/</span>')}</nav>`
51
+ }
52
+
53
+ /**
54
+ * Generate tab navigation HTML for sibling sheets
55
+ */
56
+ export function generateTabs(state: NavigationState, siblingIds: string[]): string {
57
+ if (siblingIds.length <= 1) return ''
58
+
59
+ const tabs = siblingIds.map((id) => {
60
+ const info = state.sheets.get(id)
61
+ const label = info?.label ?? id
62
+ const isActive = id === state.currentSheet
63
+
64
+ return `<button class="tab ${isActive ? 'active' : ''}" data-sheet="${escapeHtml(id)}">${escapeHtml(label)}</button>`
65
+ })
66
+
67
+ return `<div class="tabs">${tabs.join('')}</div>`
68
+ }
69
+
70
+ /**
71
+ * Generate back button HTML
72
+ */
73
+ export function generateBackButton(state: NavigationState): string {
74
+ if (state.breadcrumb.length <= 1) return ''
75
+
76
+ const parentId = state.breadcrumb[state.breadcrumb.length - 2]
77
+ const parentLabel =
78
+ parentId === 'root' ? 'Overview' : (state.sheets.get(parentId)?.label ?? parentId)
79
+
80
+ return `<button class="back-btn" data-sheet="${escapeHtml(parentId)}">
81
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
83
+ </svg>
84
+ ${escapeHtml(parentLabel)}
85
+ </button>`
86
+ }
87
+
88
+ /**
89
+ * Generate complete navigation toolbar HTML
90
+ */
91
+ export function generateNavigationToolbar(
92
+ state: NavigationState,
93
+ siblingIds: string[] = [],
94
+ ): string {
95
+ const breadcrumb = generateBreadcrumb(state)
96
+ const backBtn = generateBackButton(state)
97
+ const tabs = generateTabs(state, siblingIds)
98
+
99
+ return `<div class="nav-toolbar">
100
+ <div class="nav-row">
101
+ ${backBtn}
102
+ ${breadcrumb}
103
+ </div>
104
+ ${tabs}
105
+ </div>`
106
+ }
107
+
108
+ /**
109
+ * Get CSS styles for navigation components
110
+ */
111
+ export function getNavigationStyles(): string {
112
+ return `
113
+ .nav-toolbar {
114
+ padding: 8px 16px;
115
+ background: white;
116
+ border-bottom: 1px solid #e5e5e5;
117
+ }
118
+ .nav-row {
119
+ display: flex;
120
+ align-items: center;
121
+ gap: 12px;
122
+ }
123
+ .back-btn {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 4px;
127
+ padding: 6px 12px;
128
+ border: 1px solid #e5e5e5;
129
+ background: white;
130
+ border-radius: 6px;
131
+ cursor: pointer;
132
+ font-size: 13px;
133
+ color: #555;
134
+ transition: all 0.15s;
135
+ }
136
+ .back-btn:hover {
137
+ background: #f5f5f5;
138
+ border-color: #ccc;
139
+ }
140
+ .breadcrumb {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 4px;
144
+ font-size: 13px;
145
+ }
146
+ .breadcrumb-item {
147
+ background: none;
148
+ border: none;
149
+ color: #0066cc;
150
+ cursor: pointer;
151
+ padding: 4px 8px;
152
+ border-radius: 4px;
153
+ }
154
+ .breadcrumb-item:hover {
155
+ background: #f0f7ff;
156
+ }
157
+ .breadcrumb-current {
158
+ color: #333;
159
+ font-weight: 500;
160
+ }
161
+ .breadcrumb-sep {
162
+ color: #999;
163
+ }
164
+ .tabs {
165
+ display: flex;
166
+ gap: 4px;
167
+ margin-top: 8px;
168
+ border-bottom: 1px solid #e5e5e5;
169
+ padding-bottom: 0;
170
+ }
171
+ .tab {
172
+ padding: 8px 16px;
173
+ border: none;
174
+ background: none;
175
+ cursor: pointer;
176
+ font-size: 13px;
177
+ color: #666;
178
+ border-bottom: 2px solid transparent;
179
+ margin-bottom: -1px;
180
+ transition: all 0.15s;
181
+ }
182
+ .tab:hover {
183
+ color: #333;
184
+ background: #f5f5f5;
185
+ }
186
+ .tab.active {
187
+ color: #0066cc;
188
+ border-bottom-color: #0066cc;
189
+ font-weight: 500;
190
+ }
191
+ `
192
+ }
193
+
194
+ /**
195
+ * Get JavaScript code for navigation event handling
196
+ */
197
+ export function getNavigationScript(): string {
198
+ return `
199
+ (function() {
200
+ function navigateToSheet(sheetId) {
201
+ // Dispatch custom event for sheet navigation
202
+ var event = new CustomEvent('shumoku:navigate', {
203
+ detail: { sheetId: sheetId },
204
+ bubbles: true
205
+ });
206
+ document.dispatchEvent(event);
207
+
208
+ // For standalone HTML, show alert (sheets are not embedded yet)
209
+ console.log('[Shumoku] Navigate to sheet:', sheetId);
210
+ alert('Navigate to: ' + sheetId + '\\n\\nNote: Multi-sheet navigation requires embedded sheets.');
211
+ }
212
+
213
+ // Listen for navigation events from subgraph clicks
214
+ document.addEventListener('shumoku:navigate', function(e) {
215
+ var sheetId = e.detail && e.detail.sheetId;
216
+ if (sheetId) {
217
+ console.log('[Shumoku] Subgraph clicked, sheet:', sheetId);
218
+ alert('Navigate to: ' + sheetId + '\\n\\nNote: Multi-sheet navigation requires embedded sheets.');
219
+ }
220
+ });
221
+
222
+ // Handle breadcrumb clicks
223
+ document.querySelectorAll('.breadcrumb-item').forEach(function(btn) {
224
+ btn.addEventListener('click', function() {
225
+ var sheetId = this.getAttribute('data-sheet');
226
+ if (sheetId) navigateToSheet(sheetId);
227
+ });
228
+ });
229
+
230
+ // Handle back button clicks
231
+ document.querySelectorAll('.back-btn').forEach(function(btn) {
232
+ btn.addEventListener('click', function() {
233
+ var sheetId = this.getAttribute('data-sheet');
234
+ if (sheetId) navigateToSheet(sheetId);
235
+ });
236
+ });
237
+
238
+ // Handle tab clicks
239
+ document.querySelectorAll('.tab').forEach(function(btn) {
240
+ btn.addEventListener('click', function() {
241
+ var sheetId = this.getAttribute('data-sheet');
242
+ if (sheetId) navigateToSheet(sheetId);
243
+ });
244
+ });
245
+ })();
246
+ `
247
+ }
248
+
249
+ function escapeHtml(str: string): string {
250
+ return str
251
+ .replace(/&/g, '&amp;')
252
+ .replace(/</g, '&lt;')
253
+ .replace(/>/g, '&gt;')
254
+ .replace(/"/g, '&quot;')
255
+ .replace(/'/g, '&#39;')
256
+ }
@@ -242,6 +242,31 @@ export function initInteractive(options: InteractiveOptions): InteractiveInstanc
242
242
  )
243
243
  }
244
244
 
245
+ // ============================================
246
+ // Hierarchical Navigation
247
+ // ============================================
248
+
249
+ const handleSubgraphClick = (e: MouseEvent) => {
250
+ const target = e.target as Element
251
+ const subgraph = target.closest('.subgraph[data-has-sheet]')
252
+ if (subgraph) {
253
+ const sheetId = subgraph.getAttribute('data-sheet-id')
254
+ if (sheetId) {
255
+ e.preventDefault()
256
+ e.stopPropagation()
257
+ dispatchNavigateEvent(sheetId)
258
+ }
259
+ }
260
+ }
261
+
262
+ const dispatchNavigateEvent = (sheetId: string) => {
263
+ const event = new CustomEvent('shumoku:navigate', {
264
+ detail: { sheetId },
265
+ bubbles: true,
266
+ })
267
+ document.dispatchEvent(event)
268
+ }
269
+
245
270
  // ============================================
246
271
  // Tap for tooltip (touch devices)
247
272
  // ============================================
@@ -274,6 +299,18 @@ export function initInteractive(options: InteractiveOptions): InteractiveInstanc
274
299
  if (Math.hypot(dx, dy) < 10 && dt < 300) {
275
300
  const targetEl = document.elementFromPoint(touch.clientX, touch.clientY)
276
301
  if (targetEl) {
302
+ // Check for hierarchical navigation first
303
+ const subgraph = targetEl.closest('.subgraph[data-has-sheet]')
304
+ if (subgraph) {
305
+ const sheetId = subgraph.getAttribute('data-sheet-id')
306
+ if (sheetId) {
307
+ dispatchNavigateEvent(sheetId)
308
+ tapStart = null
309
+ return
310
+ }
311
+ }
312
+
313
+ // Otherwise show tooltip
277
314
  const info = getTooltipInfo(targetEl)
278
315
  if (info) {
279
316
  showTooltip(info.text, touch.clientX, touch.clientY)
@@ -333,6 +370,9 @@ export function initInteractive(options: InteractiveOptions): InteractiveInstanc
333
370
  svg.addEventListener('mouseleave', handleMouseLeave)
334
371
  svg.addEventListener('wheel', handleWheel, { passive: false })
335
372
 
373
+ // Hierarchical navigation: click on subgraph with sheet reference
374
+ svg.addEventListener('click', handleSubgraphClick)
375
+
336
376
  // Listen for scroll/resize to update highlight position
337
377
  window.addEventListener('scroll', handlePositionUpdate, true)
338
378
  window.addEventListener('resize', handlePositionUpdate)
@@ -351,6 +391,7 @@ export function initInteractive(options: InteractiveOptions): InteractiveInstanc
351
391
  document.removeEventListener('mouseup', handleMouseUp)
352
392
  svg.removeEventListener('mouseleave', handleMouseLeave)
353
393
  svg.removeEventListener('wheel', handleWheel)
394
+ svg.removeEventListener('click', handleSubgraphClick)
354
395
  window.removeEventListener('scroll', handlePositionUpdate, true)
355
396
  window.removeEventListener('resize', handlePositionUpdate)
356
397
  destroyTooltip()
@@ -366,5 +407,6 @@ export function initInteractive(options: InteractiveOptions): InteractiveInstanc
366
407
  },
367
408
  resetView,
368
409
  getScale,
410
+ navigateToSheet: dispatchNavigateEvent,
369
411
  }
370
412
  }
@@ -81,10 +81,36 @@ export function getTooltipInfo(el: Element): TooltipInfo | null {
81
81
 
82
82
  const linkGroup = el.closest('.link-group[data-link-id]')
83
83
  if (linkGroup) {
84
- const from = linkGroup.getAttribute('data-link-from') || ''
85
- const to = linkGroup.getAttribute('data-link-to') || ''
84
+ let from = linkGroup.getAttribute('data-link-from') || ''
85
+ let to = linkGroup.getAttribute('data-link-to') || ''
86
86
  const bw = linkGroup.getAttribute('data-link-bandwidth')
87
87
  const vlan = linkGroup.getAttribute('data-link-vlan')
88
+ const jsonAttr = linkGroup.getAttribute('data-link-json')
89
+
90
+ // For export links, show device:port ↔ destination device:port
91
+ const fromIsExport = from.startsWith('__export_')
92
+ const toIsExport = to.startsWith('__export_')
93
+ if ((fromIsExport || toIsExport) && jsonAttr) {
94
+ try {
95
+ const linkData = JSON.parse(jsonAttr)
96
+ const meta = linkData.metadata
97
+ if (meta?._destDevice) {
98
+ // Get the actual device endpoint (non-export side)
99
+ const localDevice = fromIsExport ? to : from
100
+ // Get the destination device:port from metadata
101
+ const destDevice = meta._destPort
102
+ ? `${meta._destDevice}:${meta._destPort}`
103
+ : meta._destDevice
104
+ // Show: "local device ↔ remote device"
105
+ let text = `${localDevice} ↔ ${destDevice}`
106
+ if (bw) text += `\n${bw}`
107
+ if (vlan) text += `\nVLAN: ${vlan}`
108
+ return { text, element: linkGroup }
109
+ }
110
+ } catch {
111
+ // Fall through to default display
112
+ }
113
+ }
88
114
 
89
115
  let text = `${from} ↔ ${to}`
90
116
  if (bw) text += `\n${bw}`
package/src/index.ts CHANGED
@@ -1,22 +1,25 @@
1
- /**
2
- * @shumoku/renderer - SVG and HTML renderers for network diagrams
3
- */
4
-
5
- import * as html from './html/index.js'
6
- // Namespace exports
7
- import * as svg from './svg.js'
8
-
9
- export { svg, html }
10
-
11
- // Types
12
- export type {
13
- DataAttributeOptions,
14
- DeviceInfo,
15
- EndpointInfo,
16
- HTMLRendererOptions,
17
- InteractiveInstance,
18
- InteractiveOptions,
19
- LinkInfo,
20
- PortInfo,
21
- RenderMode,
22
- } from './types.js'
1
+ /**
2
+ * @shumoku/renderer - SVG and HTML renderers for network diagrams
3
+ */
4
+
5
+ import * as html from './html/index.js'
6
+ // Namespace exports
7
+ import * as svg from './svg.js'
8
+
9
+ export { svg, html }
10
+
11
+ // Types
12
+ export type {
13
+ DataAttributeOptions,
14
+ DeviceInfo,
15
+ EndpointInfo,
16
+ HTMLRendererOptions,
17
+ InteractiveInstance,
18
+ InteractiveOptions,
19
+ LinkInfo,
20
+ PortInfo,
21
+ RenderMode,
22
+ } from './types.js'
23
+
24
+ // Re-export SheetData for hierarchical rendering
25
+ export type { SheetData } from './html/index.js'