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

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