@painekillers/tree-builder-viewer 0.0.0-stage → 1.0.1

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/viewer.js ADDED
@@ -0,0 +1,752 @@
1
+ import {
2
+ World,
3
+ Camera,
4
+ Bridge
5
+ } from "@painekillers/tree-builder-core"
6
+
7
+ import SearchIndex from "./search.js"
8
+
9
+ import {
10
+ Node,
11
+ Graph
12
+ } from "@painekillers/tree-builder-core"
13
+
14
+ import Tooltip from "./tooltip.js"
15
+
16
+ import {
17
+ ThemeContainer,
18
+ ThemeApplier
19
+ } from "./theme.js"
20
+
21
+ export default class Viewer {
22
+ #container
23
+ #shadow
24
+ #canvas
25
+ #ui
26
+
27
+ #theme
28
+
29
+ #graph
30
+ #world
31
+ #bridge
32
+
33
+ #observer
34
+
35
+ #search
36
+ #tooltip
37
+
38
+ #uiUpdates
39
+
40
+ #enabled = false
41
+
42
+ recurse = true
43
+ /*
44
+ Null: No recursion
45
+ False: Creates fake nodes when encountering recursion
46
+ True: Connects back to existing instance of a node when recursing
47
+ #: Real recursion to depths #
48
+ */
49
+
50
+ constructor(container, themes) {
51
+ this.#container = container
52
+
53
+ this.#shadow = this.#container.attachShadow({
54
+ mode: "open"
55
+ })
56
+
57
+ this.#theme = new ThemeApplier(
58
+ themes ?? new ThemeContainer(),
59
+ this.#shadow
60
+ )
61
+
62
+ this.#canvas = document.createElement("canvas")
63
+ this.#ui = document.createElement("div")
64
+ this.#ui.id = "ui"
65
+
66
+ // Init the world
67
+ this.#world = new World(
68
+ this.#canvas,
69
+ this.#theme.current
70
+ )
71
+
72
+ const cam = new Camera(this.#world)
73
+
74
+ this.#world.camera = cam
75
+
76
+ // Resize observer
77
+ this.#observer = new ResizeObserver(() => {
78
+ this.#world.resize(
79
+ this.#container.clientWidth,
80
+ this.#container.clientHeight
81
+ )
82
+ })
83
+
84
+ // Mouse interactions
85
+ let hovered = null
86
+
87
+ this.#canvas.addEventListener("mousemove", e => {
88
+ if (this.#world.paused) return
89
+
90
+ const pos = this.#world.mouseWorldPosition(e)
91
+ const object = this.#world.objectAt(pos.x, pos.y)
92
+
93
+ if (object === hovered) return
94
+
95
+ hovered?.mouseLeave?.()
96
+
97
+ hovered = object
98
+
99
+ hovered?.mouseEnter?.()
100
+ })
101
+
102
+ this.#canvas.addEventListener("mousedown", e => {
103
+ if (this.#world.paused) return
104
+
105
+ const pos = this.#world.mouseWorldPosition(e)
106
+ const object = this.#world.objectAt(pos.x, pos.y)
107
+
108
+ if(object) {
109
+ console.log("clicked node:", object.node.val)
110
+
111
+ if(object.drawNode.layoutNode.original === null) {
112
+ this.nodeMenu(object.node)
113
+ } else {
114
+ this.#bridge.focus(
115
+ object.drawNode.layoutNode.original.node
116
+ )
117
+ }
118
+ }
119
+ })
120
+
121
+ // Camera movements
122
+ let dragging = false
123
+
124
+ let lastX = 0
125
+ let lastY = 0
126
+
127
+
128
+ // Pan
129
+
130
+ this.#canvas.addEventListener("mousedown", e => {
131
+ if (e.button !== 0) return
132
+
133
+ dragging = true
134
+
135
+ lastX = e.clientX
136
+ lastY = e.clientY
137
+ })
138
+
139
+
140
+ this.#canvas.addEventListener("mouseup", () => {
141
+ dragging = false
142
+ })
143
+
144
+
145
+ this.#canvas.addEventListener("mouseleave", () => {
146
+ dragging = false
147
+ })
148
+
149
+
150
+ this.#canvas.addEventListener("mousemove", e => {
151
+ if (!dragging) return
152
+
153
+ const dx = e.clientX - lastX
154
+ const dy = e.clientY - lastY
155
+
156
+ lastX = e.clientX
157
+ lastY = e.clientY
158
+
159
+ const pos = cam.pos
160
+
161
+ cam.pos = [
162
+ pos.x - dx / cam.zoom,
163
+ pos.y - dy / cam.zoom
164
+ ]
165
+ })
166
+
167
+
168
+ // Zoom toward mouse
169
+
170
+ this.#canvas.addEventListener("wheel", e => {
171
+ e.preventDefault()
172
+
173
+ const rect = this.#canvas.getBoundingClientRect()
174
+
175
+ const mouseX =
176
+ e.clientX - rect.left - this.#canvas.width / 2
177
+
178
+ const mouseY =
179
+ e.clientY - rect.top - this.#canvas.height / 2
180
+
181
+ const oldZoom = cam.zoom
182
+
183
+ const pos = cam.pos
184
+
185
+ // World position currently underneath mouse
186
+ const worldX = pos.x + mouseX / oldZoom
187
+ const worldY = pos.y + mouseY / oldZoom
188
+
189
+ const zoomSpeed = 0.001
190
+
191
+ let newZoom =
192
+ oldZoom * (1 - e.deltaY * zoomSpeed)
193
+
194
+ newZoom = Math.max(
195
+ 0.1,
196
+ Math.min(newZoom, 5)
197
+ )
198
+
199
+ cam.zoom = newZoom
200
+
201
+ // Keep the same world point underneath the mouse
202
+ cam.pos = [
203
+ worldX - mouseX / newZoom,
204
+ worldY - mouseY / newZoom
205
+ ]
206
+ }, { passive: false })
207
+
208
+ // Init the bridge
209
+ this.#bridge = new Bridge(
210
+ null,
211
+ this.#world
212
+ )
213
+
214
+ // Init search index
215
+ this.#search = new SearchIndex([])
216
+
217
+ // Create UI
218
+ const searchButton = document.createElement("button")
219
+ searchButton.id = "search-button"
220
+ searchButton.ariaLabel = "Search"
221
+
222
+ const searchMenu = document.createElement("div")
223
+ searchMenu.id = "search-menu"
224
+
225
+ const searchHeader = document.createElement("div")
226
+ searchHeader.className = "menu-header"
227
+
228
+ const searchTitle = document.createElement("span")
229
+ searchTitle.textContent = "Search"
230
+
231
+ const searchMenuClose = document.createElement("button")
232
+ searchMenuClose.id = "search-menu-close"
233
+ searchMenuClose.textContent = "×"
234
+
235
+ searchHeader.append(
236
+ searchTitle,
237
+ searchMenuClose
238
+ )
239
+
240
+ const searchInput = document.createElement("input")
241
+ searchInput.id = "search-input"
242
+ searchInput.type = "text"
243
+ searchInput.placeholder = "Search nodes..."
244
+
245
+ const searchResults = document.createElement("div")
246
+ searchResults.id = "search-results"
247
+
248
+ searchMenu.append(
249
+ searchHeader,
250
+ searchInput,
251
+ searchResults
252
+ )
253
+
254
+
255
+ const themeButton = document.createElement("button")
256
+ themeButton.id = "theme-button"
257
+
258
+ const themeMenu = document.createElement("div")
259
+ themeMenu.id = "theme-menu"
260
+
261
+ const themeHeader = document.createElement("div")
262
+ themeHeader.className = "menu-header"
263
+
264
+ const themeTitle = document.createElement("span")
265
+ themeTitle.textContent = "Theme"
266
+
267
+ const themeMenuClose = document.createElement("button")
268
+ themeMenuClose.id = "theme-menu-close"
269
+ themeMenuClose.textContent = "×"
270
+
271
+ themeHeader.append(
272
+ themeTitle,
273
+ themeMenuClose
274
+ )
275
+
276
+ const themeGrid = document.createElement("div")
277
+ themeGrid.id = "theme-grid"
278
+
279
+ themeMenu.append(
280
+ themeHeader,
281
+ themeGrid
282
+ )
283
+
284
+
285
+ const nodeMenu = document.createElement("div")
286
+ nodeMenu.id = "node-menu"
287
+
288
+ const nodeHeader = document.createElement("div")
289
+ nodeHeader.className = "menu-header"
290
+
291
+ const nodeMenuTitle = document.createElement("span")
292
+ nodeMenuTitle.id = "node-menu-title"
293
+ nodeMenuTitle.textContent = "Node"
294
+
295
+ const nodeMenuClose = document.createElement("button")
296
+ nodeMenuClose.id = "node-menu-close"
297
+ nodeMenuClose.textContent = "×"
298
+
299
+ nodeHeader.append(
300
+ nodeMenuTitle,
301
+ nodeMenuClose
302
+ )
303
+
304
+ const nodeMenuContent = document.createElement("div")
305
+ nodeMenuContent.id = "node-menu-content"
306
+
307
+ nodeMenu.append(
308
+ nodeHeader,
309
+ nodeMenuContent
310
+ )
311
+
312
+
313
+ this.#ui.append(
314
+ searchButton,
315
+ searchMenu,
316
+ themeButton,
317
+ themeMenu,
318
+ nodeMenu
319
+ )
320
+
321
+ this.#shadow.append(
322
+ this.#canvas,
323
+ this.#ui
324
+ )
325
+
326
+ const menus = [
327
+ searchMenu,
328
+ themeMenu,
329
+ nodeMenu
330
+ ]
331
+
332
+ function closeMenus() {
333
+ for(const menu of menus) {
334
+ menu.style.display = "none"
335
+ }
336
+ }
337
+
338
+ function openMenu(menu) {
339
+ closeMenus()
340
+ menu.style.display = "block"
341
+ }
342
+
343
+ searchButton.addEventListener("click", () => {
344
+ if(searchMenu.style.display === "block") {
345
+ searchMenu.style.display = "none"
346
+ } else {
347
+ openMenu(searchMenu)
348
+ }
349
+ })
350
+
351
+ themeButton.addEventListener("click", () => {
352
+ if(themeMenu.style.display === "block") {
353
+ themeMenu.style.display = "none"
354
+ } else {
355
+ openMenu(themeMenu)
356
+ }
357
+ })
358
+
359
+ searchMenuClose.addEventListener("click", () => {
360
+ searchMenu.style.display = "none"
361
+ })
362
+
363
+ themeMenuClose.addEventListener("click", () => {
364
+ themeMenu.style.display = "none"
365
+ })
366
+
367
+ nodeMenuClose.addEventListener("click", () => {
368
+ nodeMenu.style.display = "none"
369
+ })
370
+
371
+ const blank = new Image()
372
+ blank.src = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
373
+
374
+ const previewGraph = new Graph([
375
+ new Node("A", [], ["B", "C"], {icon: blank}),
376
+ new Node("B", ["A"], [], {icon: blank}),
377
+ new Node("C", ["A"], [], {icon: blank})
378
+ ])
379
+
380
+ const updateThemeMenu = () => {
381
+ themeGrid.replaceChildren()
382
+
383
+ const hidden =
384
+ getComputedStyle(themeMenu).display === "none"
385
+
386
+ if(hidden) {
387
+ themeMenu.style.visibility = "hidden"
388
+ themeMenu.style.display = "block"
389
+ }
390
+
391
+ for(const [name, theme] of Object.entries(
392
+ this.#theme.container.themes
393
+ )) {
394
+ const entry = document.createElement("button")
395
+ entry.className = "theme-entry"
396
+
397
+ const preview = document.createElement("canvas")
398
+ preview.className = "theme-preview"
399
+
400
+ const label = document.createElement("div")
401
+ label.className = "theme-name"
402
+ label.textContent = name
403
+
404
+ entry.append(
405
+ preview,
406
+ label
407
+ )
408
+
409
+ themeGrid.append(entry)
410
+
411
+ const rect = preview.getBoundingClientRect()
412
+
413
+ preview.width = rect.width
414
+ preview.height = rect.height
415
+
416
+ const world = new World(
417
+ preview,
418
+ theme
419
+ )
420
+
421
+ world.background = theme.background
422
+
423
+ const camera = new Camera(world)
424
+
425
+ world.camera = camera
426
+
427
+ world.resize(
428
+ preview.width,
429
+ preview.height
430
+ )
431
+
432
+ const bridge = new Bridge(
433
+ previewGraph,
434
+ world
435
+ )
436
+
437
+ bridge.calculateLayout(
438
+ "A",
439
+ null
440
+ )
441
+
442
+ bridge.calculateDraw(
443
+ 50,
444
+ 50,
445
+ 70,
446
+ 50
447
+ )
448
+
449
+ bridge.createObjects(null)
450
+
451
+ camera.zoom = 0.4
452
+ camera.pos = [-40, 40]
453
+
454
+ world.update()
455
+
456
+ if(theme === this.#theme.current) {
457
+ entry.classList.add("selected")
458
+ }
459
+
460
+ entry.addEventListener("click", async () => {
461
+ await this.#theme.apply(name)
462
+
463
+ themeMenu.style.display = "none"
464
+ })
465
+ }
466
+
467
+ if(hidden) {
468
+ themeMenu.style.display = "none"
469
+ themeMenu.style.visibility = ""
470
+ }
471
+ }
472
+
473
+ const updateThemeButtons = () => {
474
+ searchButton.innerHTML =
475
+ this.#theme.current.searchButton
476
+
477
+ themeButton.innerHTML =
478
+ this.#theme.current.themeButton
479
+ }
480
+
481
+ const updateSearchResults = () => {
482
+ const query = searchInput.value
483
+
484
+ const results = query
485
+ ? this.#search.search(query)
486
+ : this.#search.entries.map(entry => ({
487
+ entry,
488
+ score: 1
489
+ }))
490
+
491
+ searchResults.replaceChildren()
492
+
493
+ for(const result of results) {
494
+ const entry = document.createElement("div")
495
+ entry.className = "search-entry"
496
+
497
+ const info = document.createElement("div")
498
+
499
+ const name = document.createElement("div")
500
+ name.className = "search-entry-name"
501
+ name.textContent = result.entry.name
502
+
503
+ info.append(name)
504
+
505
+ if(
506
+ result.entry.name !==
507
+ result.entry.value.id
508
+ ) {
509
+ const alias = document.createElement("div")
510
+ alias.className = "search-entry-alias"
511
+ alias.textContent =
512
+ `(alias of ${result.entry.value.id})`
513
+
514
+ info.append(alias)
515
+ }
516
+
517
+ const actions = document.createElement("div")
518
+ actions.className = "search-entry-actions"
519
+
520
+ const focus = document.createElement("button")
521
+ focus.dataset.tooltip = "Focus"
522
+ focus.innerHTML =
523
+ this.#theme.current.focusButton
524
+
525
+ if(
526
+ !this.#bridge.toObj?.has(
527
+ result.entry.value
528
+ )
529
+ ) {
530
+ focus.disabled = true
531
+ }
532
+
533
+ focus.addEventListener("click", () => {
534
+ this.#bridge.focus(
535
+ result.entry.value
536
+ )
537
+
538
+ searchMenu.style.display = "none"
539
+ })
540
+
541
+ const root = document.createElement("button")
542
+ root.dataset.tooltip = "Set root"
543
+ root.innerHTML =
544
+ this.#theme.current.rootButton
545
+
546
+ root.addEventListener("click", () => {
547
+ this.#bridge.calculateLayout(
548
+ result.entry.value.id,
549
+ this.recurse
550
+ )
551
+
552
+ this.#bridge.calculateDraw(
553
+ 50,
554
+ 50,
555
+ 100,
556
+ 50
557
+ )
558
+
559
+ this.#bridge.createObjects(
560
+ this.depth
561
+ )
562
+
563
+ searchMenu.style.display = "none"
564
+
565
+ updateSearchResults()
566
+ })
567
+
568
+ actions.append(
569
+ focus,
570
+ root
571
+ )
572
+
573
+ entry.append(
574
+ info,
575
+ actions
576
+ )
577
+
578
+ searchResults.append(entry)
579
+ }
580
+ }
581
+
582
+ this.nodeMenu = node => {
583
+ nodeMenuTitle.textContent = node.id
584
+ nodeMenuContent.replaceChildren()
585
+
586
+ openMenu(nodeMenu)
587
+ }
588
+
589
+ this.#theme.subscribe(() => {
590
+ this.#world.theme =
591
+ this.#theme.current
592
+
593
+ this.#world.background =
594
+ this.#theme.current.background
595
+
596
+ this.#world.update()
597
+ })
598
+
599
+ this.#world.background = this.#theme.current.background
600
+
601
+ this.#theme.subscribe(updateSearchResults)
602
+ this.#theme.subscribe(updateThemeMenu)
603
+ this.#theme.subscribe(updateThemeButtons)
604
+
605
+ searchInput.addEventListener(
606
+ "input",
607
+ updateSearchResults
608
+ )
609
+
610
+ updateThemeButtons()
611
+ updateThemeMenu()
612
+ updateSearchResults()
613
+
614
+ this.#uiUpdates = {
615
+ updateThemeButtons,
616
+ updateThemeMenu,
617
+ updateSearchResults
618
+ }
619
+
620
+ // Init tooltip
621
+ this.tooltip = {
622
+ offset: 12,
623
+ duration: 120
624
+ }
625
+ }
626
+
627
+ get graph() {
628
+ return this.#graph
629
+ }
630
+
631
+ set graph(graph) {
632
+ this.#graph = graph
633
+ this.#bridge.graph = graph
634
+
635
+ this.#search.entries =
636
+ Array.from(graph?.nodes.values()).map(node => ({
637
+ name: node.id,
638
+ value: node
639
+ }))
640
+
641
+ this.updateUi()
642
+
643
+ }
644
+
645
+ set tooltip({
646
+ offset = 8,
647
+ duration = 120
648
+ } = {}) {
649
+ if(this.#tooltip) {
650
+ this.#tooltip.offset = offset
651
+ this.#tooltip.duration = duration
652
+ return
653
+ }
654
+
655
+ this.#tooltip = new Tooltip(
656
+ this.#ui,
657
+ {
658
+ offset: offset,
659
+ duration: duration
660
+ }
661
+ )
662
+ }
663
+
664
+ updateUi() {
665
+ Object.values(this.#uiUpdates).forEach(callback => callback())
666
+ }
667
+
668
+ setRoot(root) {
669
+ if(!this.#graph) {
670
+ console.warn(
671
+ "Graph must be set before drawing"
672
+ )
673
+
674
+ return
675
+ }
676
+
677
+ this.#bridge.calculateLayout(
678
+ root,
679
+ this.recurse
680
+ )
681
+ }
682
+
683
+ setSize(w, h, px, py) {
684
+ if(!this.#bridge.root) {
685
+ console.warn(
686
+ "Root must be set before setting size"
687
+ )
688
+
689
+ return
690
+ }
691
+
692
+ this.#bridge.calculateDraw(
693
+ w ?? 50,
694
+ h ?? 50,
695
+ px ?? 100,
696
+ py ?? 50
697
+ )
698
+ }
699
+
700
+ draw(maxDepth) {
701
+ if(!this.#bridge.draw) {
702
+ console.warn(
703
+ "SetSize must be called first"
704
+ )
705
+
706
+ return
707
+ }
708
+
709
+ this.depth = maxDepth
710
+
711
+ this.#bridge.createObjects(
712
+ maxDepth ?? null
713
+ )
714
+
715
+ this.updateUi()
716
+ }
717
+
718
+ enable() {
719
+ if(this.#enabled) return
720
+
721
+ if(!this.#graph) {
722
+ console.warn(
723
+ "Graph must be set before enabling the viewer"
724
+ )
725
+
726
+ return
727
+ }
728
+
729
+ this.#enabled = true
730
+
731
+ this.#world.resize(
732
+ this.#container.clientWidth,
733
+ this.#container.clientHeight
734
+ )
735
+
736
+ this.#observer.observe(
737
+ this.#container
738
+ )
739
+ }
740
+
741
+ disable() {
742
+ if(!this.#enabled) return
743
+
744
+ this.#enabled = false
745
+
746
+ this.#observer.unobserve(
747
+ this.#container
748
+ )
749
+
750
+ this.#tooltip?.hide()
751
+ }
752
+ }