@shawcx/avispa 1.0.0 → 2.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/README.md CHANGED
@@ -1,11 +1,123 @@
1
1
  Avispa
2
2
  ======
3
3
 
4
- Javascript Graph Layout and Interaction
4
+ Draw node/link graphs in SVG and interact with them: drag nodes and groups, pan,
5
+ zoom, bend links, and resize, nest, and collapse groups, with mouse, pen, or touch.
6
+ Items are Backbone views, and an optional WebSocket feed keeps a graph live.
5
7
 
6
- Avispa (spanish for wasp) is a visualization framework. It is intended to be a
7
- general purpose Javascript library for rendering graphs.
8
+ Avispa is Spanish for wasp.
8
9
 
9
- Depends on jQuery, Underscore.js, and Backbone.js
10
+ Demo at [https://shawcx.github.io/avispa/](https://shawcx.github.io/avispa/)
10
11
 
11
- Demo at [http://shawcx.github.io/avispa/](http://shawcx.github.io/avispa/)
12
+ Install
13
+ -------
14
+
15
+ ```sh
16
+ npm install @shawcx/avispa jquery underscore backbone
17
+ ```
18
+
19
+ jQuery (3 or 4), Underscore, and Backbone are peer dependencies, so your app
20
+ supplies one copy of each. The library is CommonJS (`js/Avispa.js`), meant to be
21
+ bundled with browserify, webpack, esbuild, or similar. The theme is
22
+ `css/Avispa.css`, or `@use` `scss/Avispa.scss` from your own Sass.
23
+
24
+ Page setup
25
+ ----------
26
+
27
+ Avispa draws into an SVG on your page, which needs these layers, and an `#Arrow`
28
+ marker for the link arrowheads:
29
+
30
+ ```html
31
+ <svg class="avispa">
32
+ <defs>
33
+ <marker id="Arrow" viewBox="0 0 10 10" refX="7" refY="5"
34
+ markerWidth="4" markerHeight="4" orient="auto">
35
+ <path d="M 1 1 L 9 5 L 1 9 z" />
36
+ </marker>
37
+ </defs>
38
+ <g class="pan">
39
+ <g class="zoom">
40
+ <g class="groups"></g>
41
+ <g class="links"></g>
42
+ <g class="objects"></g>
43
+ <g class="labels"></g>
44
+ </g>
45
+ </g>
46
+ </svg>
47
+ ```
48
+
49
+ Usage
50
+ -----
51
+
52
+ Each item takes a `Position` model; moving an item means setting its position, and
53
+ the view redraws. A child's `x` and `y` are relative to its parent group.
54
+
55
+ ```js
56
+ const $ = require('jquery')
57
+ const Avispa = require('@shawcx/avispa')
58
+
59
+ $(() => {
60
+ const graph = new Avispa.Avispa({ el: $('svg.avispa') })
61
+
62
+ const group = new Avispa.Group({
63
+ position: new Avispa.Position({ x: -200, y: -75, w: 200, h: 150 }),
64
+ })
65
+ graph.$groups.append(group.$el)
66
+
67
+ const a = new Avispa.Node({
68
+ label: 'A',
69
+ parent: group,
70
+ position: new Avispa.Position({ x: 60, y: 75, radius: 30 }),
71
+ })
72
+ const b = new Avispa.Node({
73
+ label: 'B',
74
+ position: new Avispa.Position({ x: 150, y: 0, radius: 30 }),
75
+ })
76
+ graph.$objects.append(a.$el, b.$el)
77
+
78
+ new Avispa.Link({ parent: graph, left: a, right: b })
79
+
80
+ // a.position.set({ x: 80 }) moves A and its link
81
+ })
82
+ ```
83
+
84
+ On screen:
85
+
86
+ - Drag the background to pan, scroll to zoom, middle-click to recenter.
87
+ - Drag a node or group to move it; a group's children come along. A node stays
88
+ inside its group, and a nested group dragged past its parent's edge grows it.
89
+ - Drag a group's bottom-right corner to resize it, down to what its contents need.
90
+ - Drag a link to bend it; shift-click it to straighten it.
91
+
92
+ In code, `group.Collapse()`, `Expand()`, and `ToggleCollapse()` shrink a group to
93
+ its title bar and hide what's inside, and `item.remove()` takes an item off the
94
+ graph (remove its links yourself).
95
+
96
+ To customize, subclass `Avispa`, `Group`, `Node`, and `Link`, and override
97
+ `initialize` (calling `super`), `render`, and the `LeftClick`, `MiddleClick`,
98
+ `RightClick`, and `OnContextMenu` hooks. [`example/src/example.js`](example/src/example.js)
99
+ does this, adding a title to each group that collapses it on double-click.
100
+
101
+ ### WebSocket
102
+
103
+ ```js
104
+ class Graph extends Avispa.Avispa {
105
+ OnMessage(msg) {
106
+ // each JSON message from ws(s)://<page host>/ws
107
+ }
108
+ }
109
+
110
+ const graph = new Graph({ el: $('svg.avispa'), socket: 'ws' })
111
+ graph.socket.send({ hello: 'world' })
112
+ ```
113
+
114
+ The socket reconnects with backoff, keeps the connection alive with pings, and
115
+ queues anything sent before it opens. Pass an options object instead of a path
116
+ for an explicit `url` or your own `onopen` / `onclose` / `onmessage` callbacks.
117
+
118
+ Development
119
+ -----------
120
+
121
+ - `npm run example` builds the demo in `example/` and opens it in a browser.
122
+ - `npm test` runs the Playwright tests in Google Chrome.
123
+ - `npm run build` compiles `scss/Avispa.scss` to `css/Avispa.css`.
package/css/Avispa.css CHANGED
@@ -9,9 +9,14 @@ g.group rect.hover {
9
9
  stroke: #333;
10
10
  stroke-width: 2px;
11
11
  }
12
+ g.group rect.handle {
13
+ cursor: nwse-resize;
14
+ fill: #999;
15
+ stroke: none;
16
+ }
12
17
  g.group text {
13
18
  cursor: pointer;
14
- font: bold 8pt "Andale Mono", AndaleMono, monospace;
19
+ font: bold 10pt "Andale Mono", AndaleMono, monospace;
15
20
  fill: #000;
16
21
  text-anchor: left;
17
22
  }
@@ -1 +1 @@
1
- {"version":3,"sourceRoot":"","sources":["../scss/Avispa.scss"],"names":[],"mappings":"AAMI;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAKJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAKJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EAEA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA","file":"Avispa.css"}
1
+ {"version":3,"sourceRoot":"","sources":["../scss/Avispa.scss"],"names":[],"mappings":"AAMI;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAKJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAKJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EAEA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA","file":"Avispa.css"}
package/js/Avispa.js CHANGED
@@ -3,45 +3,34 @@
3
3
  const _ = require('underscore')
4
4
  const Backbone = require('backbone')
5
5
 
6
- const { Position } = require('./Types.js')
7
- const { cancelEvent, $SVG } = require('./Utils.js')
6
+ const { Position } = require('./Types.js')
7
+ const { cancelEvent, $SVG, normalizeWheel } = require('./Utils.js')
8
8
 
9
9
  const context = require('./Context.js')
10
10
  const Group = require('./Group.js')
11
11
  const Node = require('./Node.js')
12
12
  const Link = require('./Link.js')
13
-
14
- let normalizeWheel = function(event) {
15
- if (event.wheelDelta) {
16
- return event.wheelDelta / 120
17
- }
18
- if (event.detail) {
19
- return event.detail / -3
20
- }
21
- return 0
22
- }
23
-
24
-
25
- const $SVG = function(name) {
26
- return $(document.createElementNS('http://www.w3.org/2000/svg', name))
27
- }
28
-
13
+ const Socket = require('./Socket.js')
29
14
 
30
15
  class Avispa extends Backbone.View {
31
16
  events() {
32
17
  return {
33
- 'mousedown.avispa' : 'OnMouseDown',
34
- 'mousemove.avispa' : 'OnMouseMove',
35
- 'mouseup.avispa' : 'OnMouseUp',
36
- 'mousewheel.avispa' : 'OnMouseWheel',
37
- 'DOMMouseScroll.avispa' : 'OnMouseWheel',
38
- 'contextmenu.avispa' : 'OnContextMenu',
18
+ 'pointerdown.avispa' : 'OnMouseDown',
19
+ 'pointermove.avispa' : 'OnMouseMove',
20
+ 'pointerup.avispa' : 'OnMouseUp',
21
+ 'pointercancel.avispa' : 'OnPointerCancel',
22
+ 'wheel.avispa' : 'OnMouseWheel',
23
+ 'contextmenu.avispa' : 'OnContextMenu',
39
24
  }
40
25
  }
41
26
 
42
27
  initialize(options) {
43
28
  context.a = this
44
29
 
30
+ // the surface handles its own drags: keep touch from panning the page
31
+ // and drags from selecting text
32
+ this.$el.css({'touch-action': 'none', 'user-select': 'none'})
33
+
45
34
  _.bindAll(this, 'render',
46
35
  'OnMouseDown', 'OnMouseMove', 'OnMouseUp', 'OnMouseWheel', 'OnContextMenu')
47
36
 
@@ -76,6 +65,28 @@ class Avispa extends Backbone.View {
76
65
  this.$pan.y = parseInt(window.innerHeight / 2)
77
66
 
78
67
  this.Pan(0,0)
68
+
69
+ // opt-in WebSocket transport: `socket` may be a path string or a
70
+ // Socket options object. Incoming messages are routed to OnMessage
71
+ // unless the caller supplies its own onmessage handler.
72
+ if (options && options.socket) {
73
+ let socketOptions = (typeof options.socket === 'string')
74
+ ? { path: options.socket }
75
+ : Object.assign({}, options.socket)
76
+
77
+ if (!socketOptions.onmessage) {
78
+ socketOptions.onmessage = (msg) => this.OnMessage(msg)
79
+ }
80
+
81
+ this.socket = new Socket(socketOptions)
82
+ }
83
+ }
84
+
85
+ remove() {
86
+ if (this.socket) {
87
+ this.socket.close()
88
+ }
89
+ return super.remove()
79
90
  }
80
91
 
81
92
  Pan(dx, dy) {
@@ -96,9 +107,8 @@ class Avispa extends Backbone.View {
96
107
 
97
108
  Zoom(delta) {
98
109
  let scale = (delta == 0) ? 1.0 : this.scale + delta * this.zoom.step
99
- if (scale >= this.zoom.min || scale <= this.zoom.max) {
100
- this.Scale(scale)
101
- }
110
+ scale = Math.min(this.zoom.max, Math.max(this.zoom.min, scale))
111
+ this.Scale(scale)
102
112
  return this
103
113
  }
104
114
 
@@ -123,14 +133,14 @@ class Avispa extends Backbone.View {
123
133
  return cancelEvent(event)
124
134
  }
125
135
 
126
- switch (event.which) {
127
- case 1:
136
+ switch (event.button) {
137
+ case 0:
128
138
  this.LeftDown(event)
129
139
  break
130
- case 2:
140
+ case 1:
131
141
  this.MiddleDown(event)
132
142
  break
133
- case 3:
143
+ case 2:
134
144
  if (this.RightDown) {
135
145
  this.RightDown(event)
136
146
  }
@@ -174,33 +184,34 @@ class Avispa extends Backbone.View {
174
184
  this.offset = null
175
185
 
176
186
  if (this.dragItem) {
187
+ // click hooks are optional on drag items (Link has no Middle/RightClick)
177
188
  if (this.dragItem.jitter < 3) {
178
- switch (event.which) {
189
+ switch (event.button) {
190
+ case 0:
191
+ if (this.dragItem.LeftClick) this.dragItem.LeftClick(event)
192
+ break
179
193
  case 1:
180
- this.dragItem.LeftClick(event)
194
+ if (this.dragItem.MiddleClick) this.dragItem.MiddleClick(event)
181
195
  break
182
196
  case 2:
183
- this.dragItem.MiddleClick(event)
184
- break
185
- case 3:
186
- this.dragItem.RightClick(event)
197
+ if (this.dragItem.RightClick) this.dragItem.RightClick(event)
187
198
  break
188
199
  }
189
200
  }
190
- else if(this.dragItem.MouseUp) {
201
+ if (this.dragItem.MouseUp) {
191
202
  this.dragItem.MouseUp(event)
192
203
  }
193
204
  this.dragItem = null
194
205
  }
195
206
  else {
196
- switch (event.which) {
197
- case 1:
207
+ switch (event.button) {
208
+ case 0:
198
209
  this.LeftClick(event)
199
210
  break
200
- case 2:
211
+ case 1:
201
212
  this.MiddleClick(event)
202
213
  break
203
- case 3:
214
+ case 2:
204
215
  this.RightClick(event)
205
216
  break
206
217
  }
@@ -209,8 +220,18 @@ class Avispa extends Backbone.View {
209
220
  return cancelEvent(event)
210
221
  }
211
222
 
223
+ OnPointerCancel(event) {
224
+ // the browser took the pointer over: drop any pan or drag in progress
225
+ this.offset = null
226
+ this.dragItem = null
227
+ }
228
+
212
229
  OnMouseWheel(event) {
213
- this.Zoom(normalizeWheel(event))
230
+ // a zero delta (a horizontal-only scroll) would reset the zoom to 1.0
231
+ let delta = normalizeWheel(event)
232
+ if (delta) {
233
+ this.Zoom(delta)
234
+ }
214
235
  //this.$('#zoomslider').slider('option', 'value', this.scale)
215
236
  return cancelEvent(event)
216
237
  }
@@ -219,6 +240,7 @@ class Avispa extends Backbone.View {
219
240
  MiddleClick(event) {}
220
241
  RightClick(event) {}
221
242
  OnContextMenu(event) {}
243
+ OnMessage(msg) {}
222
244
  }
223
245
 
224
246
  exports.Avispa = Avispa
@@ -226,5 +248,6 @@ exports.Position = Position
226
248
  exports.Group = Group
227
249
  exports.Node = Node
228
250
  exports.Link = Link
251
+ exports.Socket = Socket
229
252
  exports.$SVG = $SVG
230
253
  exports.cancelEvent = cancelEvent
package/js/BaseObject.js CHANGED
@@ -8,13 +8,20 @@ const context = require('./Context.js')
8
8
 
9
9
 
10
10
  class BaseObject extends Backbone.View {
11
+ // render once the whole initialize chain has run, so a subclass's own
12
+ // elements exist before its render does
13
+ constructor(options) {
14
+ super(options)
15
+ this.render()
16
+ }
17
+
11
18
  events() {
12
19
  return {
13
- 'mousedown' : 'OnMouseDown',
14
- 'mouseenter' : 'OnMouseEnter',
15
- 'mouseleave' : 'OnMouseLeave',
16
- 'mouseup' : 'OnMouseUp',
17
- 'contextmenu' : 'OnContextMenu',
20
+ 'pointerdown' : 'OnMouseDown',
21
+ 'pointerenter' : 'OnMouseEnter',
22
+ 'pointerleave' : 'OnMouseLeave',
23
+ 'pointerup' : 'OnMouseUp',
24
+ 'contextmenu' : 'OnContextMenu',
18
25
  }
19
26
  }
20
27
 
@@ -26,22 +33,61 @@ class BaseObject extends Backbone.View {
26
33
  _.bindAll(this, 'OnMouseDown', 'OnMouseUp', 'OnContextMenu', 'render')
27
34
 
28
35
  this.parent = options.parent
36
+ this.children = []
29
37
  this.position = options.position
30
- if (this.position) {
31
- this.position.bind('change', this.render, this)
32
- }
33
38
 
34
39
  if (this.parent) {
40
+ this.parent.children.push(this)
35
41
  this.offset = {
36
42
  x: this.position.get('x'),
37
43
  y: this.position.get('y'),
38
44
  }
39
45
  this.ParentDrag(this.parent.position)
40
- this.parent.position.bind('change', this.ParentDrag, this)
46
+ this.listenTo(this.parent.position, 'change', this.ParentDrag)
47
+ this.$el.css('display', this.CollapsedAncestor() ? 'none' : '')
48
+ }
49
+
50
+ // bound after the initial ParentDrag: a subclass is still building its
51
+ // elements here, and the constructor renders once initialize is done
52
+ if (this.position) {
53
+ this.listenTo(this.position, 'change', this.render)
54
+ }
55
+ }
56
+
57
+ // stopListening (in Backbone's remove) drops the model listeners; leaving
58
+ // the parent's children keeps a removed item out of its sizing and collapse
59
+ remove() {
60
+ if (this.parent) {
61
+ this.parent.children = _.without(this.parent.children, this)
62
+ }
63
+ return super.remove()
64
+ }
65
+
66
+ // the outermost collapsed group containing this item, or null if it is on screen
67
+ CollapsedAncestor() {
68
+ let collapsed = null
69
+ for (let p = this.parent; p; p = p.parent) {
70
+ if (p.position.get('collapsed')) {
71
+ collapsed = p
72
+ }
73
+ }
74
+ return collapsed
75
+ }
76
+
77
+ // how far this item reaches into its parent group, from the parent's top-left
78
+ Extent() {
79
+ return [this.offset.x, this.offset.y]
80
+ }
81
+
82
+ // hide everything inside a collapsed group, and show it again on expand
83
+ UpdateVisibility() {
84
+ for (const child of this.children) {
85
+ child.$el.css('display', child.CollapsedAncestor() ? 'none' : '')
86
+ child.UpdateVisibility()
41
87
  }
42
88
  }
43
89
 
44
- size(w,h) {
90
+ Size(w,h) {
45
91
  this.position.set({w:w,h:h})
46
92
  return this
47
93
  }
package/js/Group.js CHANGED
@@ -9,10 +9,24 @@ const BaseObject = require('./BaseObject.js')
9
9
 
10
10
 
11
11
  class Group extends BaseObject {
12
+ // a collapsed group shrinks to its title bar: the 30px a child group keeps
13
+ // clear at the top in Drag
14
+ static COLLAPSED_HEIGHT = 30
15
+
16
+ // the resize handle in the bottom-right corner, and the smallest a group gets
17
+ static HANDLE = 10
18
+ static MIN_SIZE = 40
19
+
12
20
  el() {
13
21
  return $SVG('g').attr('class', 'group')
14
22
  }
15
23
 
24
+ events() {
25
+ return Object.assign(super.events(), {
26
+ 'pointerdown rect.handle' : 'OnHandleDown',
27
+ })
28
+ }
29
+
16
30
  initialize(options) {
17
31
  this.$rect = $SVG('rect')
18
32
  .attr('width', options.position.get('w'))
@@ -20,18 +34,113 @@ class Group extends BaseObject {
20
34
  .css('fill', options.position.get('fill'))
21
35
  .appendTo(this.$el)
22
36
 
37
+ this.$handle = $SVG('rect')
38
+ .attr('class', 'handle')
39
+ .attr('width', Group.HANDLE)
40
+ .attr('height', Group.HANDLE)
41
+ .appendTo(this.$el)
42
+
23
43
  super.initialize(options)
44
+
45
+ this.listenTo(this.position, 'change:collapsed', this.UpdateVisibility)
24
46
  }
25
47
 
26
48
  render() {
49
+ let x = this.position.get('x')
50
+ let y = this.position.get('y')
51
+ let w = this.position.get('w')
52
+ let h = this.Height()
53
+
27
54
  this.$rect
28
- .attr('width', this.position.get('w'))
29
- .attr('height', this.position.get('h'))
30
- .attr('x', this.position.get('x'))
31
- .attr('y', this.position.get('y'))
55
+ .attr('width', w)
56
+ .attr('height', h)
57
+ .attr('x', x)
58
+ .attr('y', y)
59
+
60
+ // no resizing while collapsed
61
+ this.$handle
62
+ .attr('x', x + w - Group.HANDLE)
63
+ .attr('y', y + h - Group.HANDLE)
64
+ .css('display', this.position.get('collapsed') ? 'none' : '')
65
+
32
66
  return this
33
67
  }
34
68
 
69
+ // drawn height; `h` keeps the expanded height while collapsed
70
+ Height() {
71
+ return this.position.get('collapsed') ? Group.COLLAPSED_HEIGHT : this.position.get('h')
72
+ }
73
+
74
+ Collapse() {
75
+ this.position.set({collapsed: true})
76
+ return this
77
+ }
78
+
79
+ Expand() {
80
+ this.position.set({collapsed: false})
81
+ return this
82
+ }
83
+
84
+ ToggleCollapse() {
85
+ this.position.set({collapsed: !this.position.get('collapsed')})
86
+ return this
87
+ }
88
+
89
+ // set the size, but no smaller than the group's contents, and grow the
90
+ // parent group if this one no longer fits inside it
91
+ Resize(w, h) {
92
+ let [minw, minh] = this.MinSize()
93
+ this.Size(Math.max(w, minw), Math.max(h, minh))
94
+
95
+ if (this.parent) {
96
+ let [pw, ph] = this.Extent()
97
+ this.parent.Resize(
98
+ Math.max(this.parent.position.get('w'), pw),
99
+ Math.max(this.parent.position.get('h'), ph))
100
+ }
101
+ return this
102
+ }
103
+
104
+ // the smallest size that still holds every child
105
+ MinSize() {
106
+ let w = Group.MIN_SIZE
107
+ let h = Group.MIN_SIZE
108
+ for (const child of this.children) {
109
+ let [cw, ch] = child.Extent()
110
+ w = Math.max(w, cw)
111
+ h = Math.max(h, ch)
112
+ }
113
+ return [w, h]
114
+ }
115
+
116
+ // the room this group needs inside its parent, keeping the 10px margin of Drag
117
+ Extent() {
118
+ return [
119
+ this.offset.x + this.position.get('w') + 10,
120
+ this.offset.y + this.Height() + 10,
121
+ ]
122
+ }
123
+
124
+ // dragging the handle resizes the group, through a drag item of its own so
125
+ // the group's click hooks don't fire for it
126
+ OnHandleDown(event) {
127
+ let start = {
128
+ x: event.clientX,
129
+ y: event.clientY,
130
+ w: this.position.get('w'),
131
+ h: this.position.get('h'),
132
+ }
133
+
134
+ context.a.dragItem = {
135
+ jitter: 0,
136
+ Drag: (event) => this.Resize(
137
+ start.w + (event.clientX - start.x) / context.a.scale,
138
+ start.h + (event.clientY - start.y) / context.a.scale),
139
+ }
140
+
141
+ return cancelEvent(event)
142
+ }
143
+
35
144
  OnMouseEnter(event) {
36
145
  if (!context.a.dragItem) {
37
146
  this.$rect.attr('class', 'hover')
@@ -58,7 +167,7 @@ class Group extends BaseObject {
58
167
  this.offset.y = this.oy1 + y
59
168
 
60
169
  let boundsx = this.parent.position.get('w') - this.position.get('w') - 10
61
- let boundsy = this.parent.position.get('h') - this.position.get('h') - 10
170
+ let boundsy = this.parent.position.get('h') - this.Height() - 10
62
171
 
63
172
  if (this.offset.x < 10) {
64
173
  this.offset.x = 10
package/js/Link.js CHANGED
@@ -10,16 +10,22 @@ const context = require('./Context.js')
10
10
  class Link extends Backbone.View {
11
11
  static RAD = 180.0 / Math.PI
12
12
 
13
+ // render once the whole initialize chain has run, as BaseObject does
14
+ constructor(options) {
15
+ super(options)
16
+ this.render()
17
+ }
18
+
13
19
  el() {
14
20
  return $SVG('g').attr('class', 'link')
15
21
  }
16
22
 
17
23
  events() {
18
24
  return {
19
- 'mousedown' : 'OnMouseDown',
20
- 'mouseenter' : 'OnMouseEnter',
21
- 'mouseleave' : 'OnMouseLeave',
22
- 'contextmenu' : 'OnContextMenu',
25
+ 'pointerdown' : 'OnMouseDown',
26
+ 'pointerenter' : 'OnMouseEnter',
27
+ 'pointerleave' : 'OnMouseLeave',
28
+ 'contextmenu' : 'OnContextMenu',
23
29
  }
24
30
  }
25
31
 
@@ -32,14 +38,19 @@ class Link extends Backbone.View {
32
38
  _.bindAll(this, 'render', 'OnMouseDown', 'OnMouseEnter', 'OnMouseLeave', 'OnContextMenu')
33
39
 
34
40
  this.arc = new Backbone.Model({arc:10})
35
- this.arc.bind('change', this.render, this)
41
+ this.listenTo(this.arc, 'change', this.render)
36
42
 
37
43
  this.left = options.left
38
44
  this.right = options.right
39
- this.left.position.bind('change', this.render, this)
40
- this.right.position.bind('change', this.render, this)
45
+ this.listenTo(this.left.position, 'change', this.render)
46
+ this.listenTo(this.right.position, 'change', this.render)
41
47
 
42
- this.render()
48
+ // re-render when a group around either end collapses or expands
49
+ for (const end of [this.left, this.right]) {
50
+ for (let p = end.parent; p; p = p.parent) {
51
+ this.listenTo(p.position, 'change:collapsed', this.render)
52
+ }
53
+ }
43
54
 
44
55
  if (options.parent) {
45
56
  options.parent.$links.append(this.$el)
@@ -51,14 +62,49 @@ class Link extends Backbone.View {
51
62
  return
52
63
  }
53
64
 
65
+ // the items the ends attach to: an end hidden inside a collapsed group
66
+ // attaches to that group instead
67
+ Ends() {
68
+ return [
69
+ this.left.CollapsedAncestor() || this.left,
70
+ this.right.CollapsedAncestor() || this.right,
71
+ ]
72
+ }
73
+
74
+ // the point a link aims at: a node's center, or the middle of a collapsed group
75
+ static Center(item) {
76
+ let p = item.position
77
+ if (p.get('collapsed')) {
78
+ return [p.get('x') + p.get('w') / 2, p.get('y') + item.Height() / 2]
79
+ }
80
+ return [p.get('x'), p.get('y')]
81
+ }
82
+
83
+ // distance from Center(item) to the item's edge along the unit vector (ux, uy)
84
+ static Edge(item, ux, uy) {
85
+ if (!item.position.get('collapsed')) {
86
+ return 30
87
+ }
88
+ return Math.min(
89
+ item.position.get('w') / 2 / Math.abs(ux),
90
+ item.Height() / 2 / Math.abs(uy))
91
+ }
92
+
54
93
  render() {
55
94
  if (!this.arc) { return this }
56
95
 
96
+ let [left, right] = this.Ends()
97
+
98
+ // both ends are hidden inside the same collapsed group
99
+ if (left === right) {
100
+ this.$el.css('display', 'none')
101
+ return this
102
+ }
103
+ this.$el.css('display', '')
104
+
57
105
  let arc = this.arc.get('arc')
58
- let lx = this.left.position.get('x')
59
- let ly = this.left.position.get('y')
60
- let rx = this.right.position.get('x')
61
- let ry = this.right.position.get('y')
106
+ let [lx, ly] = Link.Center(left)
107
+ let [rx, ry] = Link.Center(right)
62
108
 
63
109
  // calculate the angle between 2 nodes
64
110
  let ang = Math.atan2(rx - lx, ry - ly)
@@ -66,11 +112,15 @@ class Link extends Backbone.View {
66
112
  // bound the offset to about half the circle
67
113
  let offset = Math.max(-1.5, Math.min(1.5, arc / 100))
68
114
 
69
- // draw to the edge of the node
70
- lx += 30 * Math.sin(ang + offset)
71
- ly += 30 * Math.cos(ang + offset)
72
- rx += -33 * Math.sin(ang - offset)
73
- ry += -33 * Math.cos(ang - offset)
115
+ // draw to the edge of each end, leaving room for the arrowhead
116
+ let la = ang + offset
117
+ let ra = ang - offset
118
+ let ld = Link.Edge(left, Math.sin(la), Math.cos(la))
119
+ let rd = Link.Edge(right, Math.sin(ra), Math.cos(ra)) + 3
120
+ lx += ld * Math.sin(la)
121
+ ly += ld * Math.cos(la)
122
+ rx -= rd * Math.sin(ra)
123
+ ry -= rd * Math.cos(ra)
74
124
 
75
125
  // calculate the the position for the quadratic bezier curve
76
126
  let xc = ((lx + rx) >> 1) + arc * Math.cos(ang)
@@ -79,11 +129,11 @@ class Link extends Backbone.View {
79
129
  let mx = xc - (arc>>1) * Math.cos(ang)
80
130
  let my = yc + (arc>>1) * Math.sin(ang)
81
131
 
82
- let rot = -(this.RAD * ang)
132
+ let rot = -(Link.RAD * ang)
83
133
  rot += (rot > 0 && rot < 180) ? -90 : 90
84
134
 
85
- this.path.attr('d', "M #{lx} #{ly} Q #{xc} #{yc} #{rx} #{ry}")
86
- //this.label.attr('x', mx).attr('y', my).attr('transform', "rotate(#{rot}, #{mx} #{my})")
135
+ this.path.attr('d', `M ${lx} ${ly} Q ${xc} ${yc} ${rx} ${ry}`)
136
+ //this.label.attr('x', mx).attr('y', my).attr('transform', `rotate(${rot}, ${mx} ${my})`)
87
137
 
88
138
  return this
89
139
  }
@@ -93,15 +143,14 @@ class Link extends Backbone.View {
93
143
  Drag(event) {
94
144
  let [x,y] = context.a.Point(event)
95
145
 
96
- let from_x = this.left.position.get('x')
97
- let from_y = this.left.position.get('y')
98
- let to_x = this.right.position.get('x')
99
- let to_y = this.right.position.get('y')
146
+ let [left, right] = this.Ends()
147
+ let [from_x, from_y] = Link.Center(left)
148
+ let [to_x, to_y] = Link.Center(right)
100
149
 
101
150
  let d = (to_x - from_x) * (y - from_y) - (to_y - from_y) * (x - from_x)
102
151
 
103
152
  if (d) {
104
- d = Math.pow(Math.abs(d), 0.5) * (d > 0) ? -1 : 1
153
+ d = Math.pow(Math.abs(d), 0.5) * ((d > 0) ? -1 : 1)
105
154
  }
106
155
 
107
156
  if (!this.od && this.od != 0) {
@@ -123,7 +172,7 @@ class Link extends Backbone.View {
123
172
  }
124
173
 
125
174
  MouseUp(event) {
126
- if (this.jitter > 3) {
175
+ if (this.jitter >= 3) {
127
176
  this.path.css('stroke-width', '3px')
128
177
  }
129
178
  }
package/js/Node.js CHANGED
@@ -27,8 +27,6 @@ class Node extends BaseObject {
27
27
  .appendTo(this.$el)
28
28
 
29
29
  super.initialize(options)
30
-
31
- this.render()
32
30
  }
33
31
 
34
32
  render() {
@@ -41,6 +39,12 @@ class Node extends BaseObject {
41
39
  return this
42
40
  }
43
41
 
42
+ // the whole circle, not just its center, belongs inside the parent group
43
+ Extent() {
44
+ let r = this.position.get('radius') || 0
45
+ return [this.offset.x + r, this.offset.y + r]
46
+ }
47
+
44
48
  OnMouseEnter(event) {
45
49
  if (!context.a.dragItem) {
46
50
  this.$circle.attr('class', 'hover')
package/js/Socket.js ADDED
@@ -0,0 +1,134 @@
1
+ 'use strict'
2
+
3
+
4
+ const DEFAULTS = {
5
+ url : null, // full ws(s):// URL; overrides `path` when set
6
+ path : 'ws', // path appended to the current host
7
+ timeout : 0.9375, // seconds before the first reconnect attempt
8
+ maxtime : 60, // reconnect backoff is capped at this many seconds
9
+ ping : 300, // seconds between keepalive pings (0 disables)
10
+ onopen : function(event) {},
11
+ onclose : function(event) {},
12
+ onerror : function(error) {},
13
+ onmessage : function(msg) {},
14
+ }
15
+
16
+
17
+ class Socket {
18
+ constructor(options) {
19
+ this.options = Object.assign({}, DEFAULTS, options)
20
+
21
+ if (this.options.url) {
22
+ this.wsurl = this.options.url
23
+ }
24
+ else {
25
+ let protocol = location.protocol.replace('http', 'ws')
26
+ this.wsurl = `${protocol}//${location.host}/${this.options.path}`
27
+ }
28
+
29
+ this.timeout = this.options.timeout
30
+ this.interval = null
31
+ this.retry = null
32
+ this.queue = []
33
+ this.closed = false
34
+
35
+ this.connect()
36
+ }
37
+
38
+ connect() {
39
+ this.ws = new WebSocket(this.wsurl)
40
+
41
+ this.ws.onopen = (event) => {
42
+ // successful connection: reset the backoff and flush queued sends
43
+ this.timeout = this.options.timeout
44
+
45
+ for (const message of this.queue.splice(0)) {
46
+ this.ws.send(message)
47
+ }
48
+
49
+ if (this.options.ping) {
50
+ this.interval = setInterval(() => {
51
+ if (this.ws.readyState === WebSocket.OPEN) {
52
+ this.ws.send('ping')
53
+ }
54
+ }, 1000 * this.options.ping)
55
+ }
56
+
57
+ this.options.onopen(event)
58
+ }
59
+
60
+ this.ws.onmessage = (event) => {
61
+ if (event.data === 'pong') {
62
+ return
63
+ }
64
+
65
+ let msg
66
+ try {
67
+ msg = JSON.parse(event.data)
68
+ }
69
+ catch (error) {
70
+ this.options.onerror(error)
71
+ return
72
+ }
73
+
74
+ this.options.onmessage(msg)
75
+ }
76
+
77
+ this.ws.onerror = (event) => {
78
+ this.options.onerror(event)
79
+ }
80
+
81
+ this.ws.onclose = (event) => {
82
+ clearInterval(this.interval)
83
+ this.interval = null
84
+
85
+ this.options.onclose(event)
86
+
87
+ // application-level signal to force a full page reload
88
+ if (event.code === 4004) {
89
+ window.location.reload()
90
+ return
91
+ }
92
+
93
+ // deliberate close() — do not reconnect
94
+ if (this.closed) {
95
+ return
96
+ }
97
+
98
+ this.retry = setTimeout(() => {
99
+ this.retry = null
100
+ this.connect()
101
+ }, 1000 * this.timeout)
102
+
103
+ // exponential backoff on every attempt, capped at maxtime
104
+ this.timeout = Math.min(this.timeout * 2, this.options.maxtime)
105
+ }
106
+ }
107
+
108
+ send(message) {
109
+ this.sendraw(JSON.stringify(message))
110
+ }
111
+
112
+ sendraw(message) {
113
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
114
+ this.ws.send(message)
115
+ }
116
+ else {
117
+ // queued until the (re)connection opens
118
+ this.queue.push(message)
119
+ }
120
+ }
121
+
122
+ close() {
123
+ this.closed = true
124
+ clearInterval(this.interval)
125
+ clearTimeout(this.retry)
126
+ this.interval = null
127
+ this.retry = null
128
+ if (this.ws) {
129
+ this.ws.close()
130
+ }
131
+ }
132
+ }
133
+
134
+ module.exports = Socket
package/js/Types.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const Backbone = require('backbone')
4
4
 
5
5
  class Position extends Backbone.Model {}
6
- class Arc extends Backbone.Model {}
6
+ class Arc extends Backbone.Model {}
7
7
 
8
8
  exports.Position = Position
9
9
  exports.Arc = Arc
package/js/Utils.js CHANGED
@@ -2,15 +2,23 @@
2
2
 
3
3
  const $ = require('jquery')
4
4
 
5
-
6
5
  exports.cancelEvent = function(event) {
7
6
  event.preventDefault()
8
7
  event.stopPropagation()
9
8
  return false
10
9
  }
11
10
 
11
+ exports.$SVG = function(name) {
12
+ return $(document.createElementNS('http://www.w3.org/2000/svg', name))
13
+ }
14
+
15
+ // zoom steps for a `wheel` event: about 1 per mouse-wheel notch, positive when
16
+ // scrolling up. jQuery doesn't copy the deltas, so read the native event.
12
17
  exports.normalizeWheel = function(event) {
13
- if (event.wheelDelta) return event.wheelDelta / 120
14
- if (event.detail) return event.detail / -3
15
- return 0
18
+ event = event.originalEvent || event
19
+ switch (event.deltaMode) {
20
+ case 0: return -event.deltaY / 100 // pixels: ~100 per notch
21
+ case 1: return -event.deltaY / 3 // lines: 3 per notch
22
+ default: return -Math.sign(event.deltaY) // pages
23
+ }
16
24
  }
package/package.json CHANGED
@@ -1,12 +1,29 @@
1
1
  {
2
2
  "name": "@shawcx/avispa",
3
- "version": "1.0.0",
4
- "description": "SVG + Websockets",
3
+ "version": "2.0.0",
4
+ "description": "Draw node/link graphs in SVG and drag, pan, zoom, and collapse them; Backbone views with an optional WebSocket feed",
5
+ "keywords": [
6
+ "svg",
7
+ "graph",
8
+ "network",
9
+ "diagram",
10
+ "backbone",
11
+ "websocket"
12
+ ],
5
13
  "main": "js/Avispa.js",
6
14
  "sass": "scss/Avispa.scss",
7
15
  "style": "css/Avispa.css",
16
+ "files": [
17
+ "js",
18
+ "css",
19
+ "scss"
20
+ ],
8
21
  "scripts": {
9
- "build": "npx sass scss/Avispa.scss css/Avispa.css"
22
+ "build": "npx sass scss/Avispa.scss css/Avispa.css",
23
+ "example": "npm --prefix example install && npm --prefix example start",
24
+ "test": "playwright test",
25
+ "pretest": "npm --prefix example install --no-audit --no-fund && npm --prefix example run build",
26
+ "publish:skunk": "SKUNK_NPM_TOKEN=$(glab config get token --host code.skunk.tools) npm publish --@shawcx:registry=https://code.skunk.tools/api/v4/projects/69/packages/npm/"
10
27
  },
11
28
  "repository": {
12
29
  "type": "git",
@@ -18,12 +35,16 @@
18
35
  "url": "https://github.com/shawcx/avispa/issues"
19
36
  },
20
37
  "homepage": "https://github.com/shawcx/avispa#readme",
21
- "dependencies": {
38
+ "peerDependencies": {
22
39
  "backbone": "^1.4.1",
23
- "jquery": "^3.6.3",
40
+ "jquery": "^3.6.3 || ^4.0.0",
24
41
  "underscore": "^1.13.6"
25
42
  },
26
43
  "devDependencies": {
27
- "sass": "^1.58.2"
44
+ "@playwright/test": "^1.64.1",
45
+ "backbone": "^1.4.1",
46
+ "jquery": "^3.6.3 || ^4.0.0",
47
+ "sass": "^1.58.2",
48
+ "underscore": "^1.13.6"
28
49
  }
29
50
  }
package/scss/Avispa.scss CHANGED
@@ -17,9 +17,15 @@ g.group {
17
17
  stroke-width: 2px;
18
18
  }
19
19
 
20
+ rect.handle {
21
+ cursor: nwse-resize;
22
+ fill: #999;
23
+ stroke: none;
24
+ }
25
+
20
26
  text {
21
27
  cursor: pointer;
22
- font: bold 8pt $mono;
28
+ font: bold 10pt $mono;
23
29
  fill: #000;
24
30
  text-anchor: left;
25
31
  }