@shawcx/avispa 1.0.0 → 2.1.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,124 @@
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 or pinch to zoom in on the spot under the
87
+ pointer or between your fingers, and middle-click to recenter.
88
+ - Drag a node or group to move it; a group's children come along. A node stays
89
+ inside its group, and a nested group dragged past its parent's edge grows it.
90
+ - Drag a group's bottom-right corner to resize it, down to what its contents need.
91
+ - Drag a link to bend it; shift-click it to straighten it.
92
+
93
+ In code, `group.Collapse()`, `Expand()`, and `ToggleCollapse()` shrink a group to
94
+ its title bar and hide what's inside, and `item.remove()` takes an item off the
95
+ graph (remove its links yourself).
96
+
97
+ To customize, subclass `Avispa`, `Group`, `Node`, and `Link`, and override
98
+ `initialize` (calling `super`), `render`, and the `LeftClick`, `MiddleClick`,
99
+ `RightClick`, and `OnContextMenu` hooks. [`example/src/example.js`](example/src/example.js)
100
+ does this, adding a title to each group that collapses it on double-click.
101
+
102
+ ### WebSocket
103
+
104
+ ```js
105
+ class Graph extends Avispa.Avispa {
106
+ OnMessage(msg) {
107
+ // each JSON message from ws(s)://<page host>/ws
108
+ }
109
+ }
110
+
111
+ const graph = new Graph({ el: $('svg.avispa'), socket: 'ws' })
112
+ graph.socket.send({ hello: 'world' })
113
+ ```
114
+
115
+ The socket reconnects with backoff, keeps the connection alive with pings, and
116
+ queues anything sent before it opens. Pass an options object instead of a path
117
+ for an explicit `url` or your own `onopen` / `onclose` / `onmessage` callbacks.
118
+
119
+ Development
120
+ -----------
121
+
122
+ - `npm run example` builds the demo in `example/` and opens it in a browser.
123
+ - `npm test` runs the Playwright tests in Google Chrome.
124
+ - `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,47 +3,38 @@
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 {
16
+ static TOUCH_EVENTS = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']
17
+
31
18
  events() {
32
19
  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',
20
+ 'pointerdown.avispa' : 'OnMouseDown',
21
+ 'pointermove.avispa' : 'OnMouseMove',
22
+ 'pointerup.avispa' : 'OnMouseUp',
23
+ 'pointercancel.avispa' : 'OnPointerCancel',
24
+ 'wheel.avispa' : 'OnMouseWheel',
25
+ 'contextmenu.avispa' : 'OnContextMenu',
39
26
  }
40
27
  }
41
28
 
42
29
  initialize(options) {
43
30
  context.a = this
44
31
 
32
+ // the surface handles its own drags: keep touch from panning the page
33
+ // and drags from selecting text
34
+ this.$el.css({'touch-action': 'none', 'user-select': 'none'})
35
+
45
36
  _.bindAll(this, 'render',
46
- 'OnMouseDown', 'OnMouseMove', 'OnMouseUp', 'OnMouseWheel', 'OnContextMenu')
37
+ 'OnMouseDown', 'OnMouseMove', 'OnMouseUp', 'OnMouseWheel', 'OnContextMenu', 'OnTouch')
47
38
 
48
39
  this.scale = 1.0
49
40
  this.links = {}
@@ -51,6 +42,15 @@ class Avispa extends Backbone.View {
51
42
  this.dragItem = null
52
43
  this.arrow = null
53
44
 
45
+ // touch points by pointerId, as client [x, y], and the pinch two of
46
+ // them make. Watched in the capture phase, since items stop pointerdown
47
+ // from bubbling up to the surface
48
+ this.touches = new Map()
49
+ this.pinch = null
50
+ for (const type of Avispa.TOUCH_EVENTS) {
51
+ this.el.addEventListener(type, this.OnTouch, true)
52
+ }
53
+
54
54
  this.position = {
55
55
  x: 0,
56
56
  y: 0
@@ -72,10 +72,35 @@ class Avispa extends Backbone.View {
72
72
  this.$objects = this.$el.find('g.objects')
73
73
  this.$labels = this.$el.find('g.labels')
74
74
 
75
- this.$pan.x = parseInt(window.innerWidth / 2)
76
- this.$pan.y = parseInt(window.innerHeight / 2)
75
+ this.$pan.x = 0
76
+ this.$pan.y = 0
77
+
78
+ this.Center()
79
+
80
+ // opt-in WebSocket transport: `socket` may be a path string or a
81
+ // Socket options object. Incoming messages are routed to OnMessage
82
+ // unless the caller supplies its own onmessage handler.
83
+ if (options && options.socket) {
84
+ let socketOptions = (typeof options.socket === 'string')
85
+ ? { path: options.socket }
86
+ : Object.assign({}, options.socket)
87
+
88
+ if (!socketOptions.onmessage) {
89
+ socketOptions.onmessage = (msg) => this.OnMessage(msg)
90
+ }
91
+
92
+ this.socket = new Socket(socketOptions)
93
+ }
94
+ }
77
95
 
78
- this.Pan(0,0)
96
+ remove() {
97
+ if (this.socket) {
98
+ this.socket.close()
99
+ }
100
+ for (const type of Avispa.TOUCH_EVENTS) {
101
+ this.el.removeEventListener(type, this.OnTouch, true)
102
+ }
103
+ return super.remove()
79
104
  }
80
105
 
81
106
  Pan(dx, dy) {
@@ -94,26 +119,66 @@ class Avispa extends Backbone.View {
94
119
  return this
95
120
  }
96
121
 
97
- Zoom(delta) {
98
- 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)
122
+ // put the surface's origin in the middle of the SVG
123
+ Center() {
124
+ let [x, y] = this.Middle()
125
+ return this.Pan(parseInt(x) - this.$pan.x, parseInt(y) - this.$pan.y)
126
+ }
127
+
128
+ // zoom by `delta` steps (0 resets to 1), keeping what's drawn at `at`
129
+ // where it is (see ZoomTo)
130
+ Zoom(delta, at) {
131
+ return this.ZoomTo((delta == 0) ? 1.0 : this.scale + delta * this.zoom.step, at)
132
+ }
133
+
134
+ // zoom to `scale` within the limits, keeping what's drawn at `at` (an
135
+ // [x, y] from Local) where it is; without `at`, the origin stays put
136
+ ZoomTo(scale, at) {
137
+ scale = Math.min(this.zoom.max, Math.max(this.zoom.min, scale))
138
+ if (!at) {
139
+ return this.Scale(scale)
101
140
  }
102
- return this
141
+ let point = this.Surface(at)
142
+ this.Scale(scale)
143
+ return this.PanTo(point, at)
103
144
  }
104
145
 
105
- Point(event) {
106
- // translates the client x,y into the surface x,y
146
+ // pan so the surface point `point` is drawn at `at` (an [x, y] from Local)
147
+ PanTo(point, at) {
148
+ return this.Pan(
149
+ at[0] - point[0] * this.scale - this.$pan.x,
150
+ at[1] - point[1] * this.scale - this.$pan.y)
151
+ }
152
+
153
+ // translates a client x,y into the SVG's own coordinates, before the pan
154
+ // and zoom; an SVG that isn't laid out (display: none) has no transform
155
+ Local(x, y) {
156
+ let ctm = this.el.getScreenCTM()
157
+ if (!ctm) {
158
+ return [x, y]
159
+ }
107
160
  let point = this.el.createSVGPoint()
108
- point.x = event.clientX
109
- point.y = event.clientY
110
- point = point.matrixTransform(this.el.getScreenCTM().inverse())
161
+ point.x = x
162
+ point.y = y
163
+ point = point.matrixTransform(ctm.inverse())
164
+ return [point.x, point.y]
165
+ }
111
166
 
112
- // account for the current pan and scale
113
- point.x = parseInt((point.x - this.$pan.x) / this.scale)
114
- point.y = parseInt((point.y - this.$pan.y) / this.scale)
167
+ // the middle of the SVG, from Local
168
+ Middle() {
169
+ let box = this.el.getBoundingClientRect()
170
+ return this.Local(box.left + box.width / 2, box.top + box.height / 2)
171
+ }
115
172
 
116
- return [point.x, point.y]
173
+ // the surface point drawn at `at` (an [x, y] from Local)
174
+ Surface(at) {
175
+ return [(at[0] - this.$pan.x) / this.scale, (at[1] - this.$pan.y) / this.scale]
176
+ }
177
+
178
+ Point(event) {
179
+ // translates the client x,y into the surface x,y
180
+ let [x, y] = this.Surface(this.Local(event.clientX, event.clientY))
181
+ return [parseInt(x), parseInt(y)]
117
182
  }
118
183
 
119
184
  OnMouseDown(event) {
@@ -123,14 +188,14 @@ class Avispa extends Backbone.View {
123
188
  return cancelEvent(event)
124
189
  }
125
190
 
126
- switch (event.which) {
127
- case 1:
191
+ switch (event.button) {
192
+ case 0:
128
193
  this.LeftDown(event)
129
194
  break
130
- case 2:
195
+ case 1:
131
196
  this.MiddleDown(event)
132
197
  break
133
- case 3:
198
+ case 2:
134
199
  if (this.RightDown) {
135
200
  this.RightDown(event)
136
201
  }
@@ -146,12 +211,19 @@ class Avispa extends Backbone.View {
146
211
  }
147
212
 
148
213
  MiddleDown(event) {
149
- this.Pan(-this.$pan.x + window.innerWidth / 2, -this.$pan.y + window.innerHeight / 2)
214
+ this.Center()
150
215
  this.Zoom(0)
151
216
  //this.$('#zoomslider').slider('option', 'value', 1)
152
217
  }
153
218
 
154
219
  OnMouseMove(event) {
220
+ // once a drag is moving, capture its pointer so the drag carries on
221
+ // outside the SVG and its release comes back here. Not on pointerdown:
222
+ // a plain click or double-click would then go to the SVG, not the item
223
+ if ((this.offset || this.dragItem) && !this.el.hasPointerCapture(event.pointerId)) {
224
+ this.el.setPointerCapture(event.pointerId)
225
+ }
226
+
155
227
  // drag the entire scene around
156
228
  if (this.offset) {
157
229
  this.Pan(event.clientX - this.offset[0], event.clientY - this.offset[1])
@@ -174,33 +246,34 @@ class Avispa extends Backbone.View {
174
246
  this.offset = null
175
247
 
176
248
  if (this.dragItem) {
249
+ // click hooks are optional on drag items (Link has no Middle/RightClick)
177
250
  if (this.dragItem.jitter < 3) {
178
- switch (event.which) {
251
+ switch (event.button) {
252
+ case 0:
253
+ if (this.dragItem.LeftClick) this.dragItem.LeftClick(event)
254
+ break
179
255
  case 1:
180
- this.dragItem.LeftClick(event)
256
+ if (this.dragItem.MiddleClick) this.dragItem.MiddleClick(event)
181
257
  break
182
258
  case 2:
183
- this.dragItem.MiddleClick(event)
184
- break
185
- case 3:
186
- this.dragItem.RightClick(event)
259
+ if (this.dragItem.RightClick) this.dragItem.RightClick(event)
187
260
  break
188
261
  }
189
262
  }
190
- else if(this.dragItem.MouseUp) {
263
+ if (this.dragItem.MouseUp) {
191
264
  this.dragItem.MouseUp(event)
192
265
  }
193
266
  this.dragItem = null
194
267
  }
195
268
  else {
196
- switch (event.which) {
197
- case 1:
269
+ switch (event.button) {
270
+ case 0:
198
271
  this.LeftClick(event)
199
272
  break
200
- case 2:
273
+ case 1:
201
274
  this.MiddleClick(event)
202
275
  break
203
- case 3:
276
+ case 2:
204
277
  this.RightClick(event)
205
278
  break
206
279
  }
@@ -209,8 +282,65 @@ class Avispa extends Backbone.View {
209
282
  return cancelEvent(event)
210
283
  }
211
284
 
285
+ OnPointerCancel(event) {
286
+ // the browser took the pointer over: drop any pan or drag in progress
287
+ this.offset = null
288
+ this.dragItem = null
289
+ }
290
+
291
+ // A second finger turns whatever the first one started into a pinch,
292
+ // which zooms by how far apart the fingers move and pans with their
293
+ // midpoint. It lasts until every finger is up, and until then the touches
294
+ // go no further than here: lifting the last finger isn't a click.
295
+ OnTouch(event) {
296
+ // only touches that started here: one whose release never comes would
297
+ // turn every later single touch into a pinch
298
+ if (event.pointerType !== 'touch' ||
299
+ (event.type !== 'pointerdown' && !this.touches.has(event.pointerId))) {
300
+ return
301
+ }
302
+
303
+ if (event.type === 'pointerup' || event.type === 'pointercancel') {
304
+ this.touches.delete(event.pointerId)
305
+ }
306
+ else {
307
+ this.touches.set(event.pointerId, [event.clientX, event.clientY])
308
+ }
309
+
310
+ let [a, b] = this.touches.values()
311
+ let middle = () => this.Local((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
312
+
313
+ if (event.type !== 'pointermove' && this.touches.size >= 2) {
314
+ // a pinch starts, or its fingers changed: start over from here
315
+ this.offset = null
316
+ this.dragItem = null
317
+ this.pinch = {
318
+ distance : Math.max(Math.hypot(b[0] - a[0], b[1] - a[1]), 1),
319
+ scale : this.scale,
320
+ point : this.Surface(middle()),
321
+ }
322
+ }
323
+ else if (event.type === 'pointermove' && this.pinch && this.touches.size >= 2) {
324
+ let distance = Math.hypot(b[0] - a[0], b[1] - a[1])
325
+ this.ZoomTo(this.pinch.scale * distance / this.pinch.distance)
326
+ this.PanTo(this.pinch.point, middle())
327
+ }
328
+
329
+ if (this.pinch) {
330
+ if (this.touches.size === 0) {
331
+ this.pinch = null
332
+ }
333
+ event.preventDefault()
334
+ event.stopImmediatePropagation()
335
+ }
336
+ }
337
+
212
338
  OnMouseWheel(event) {
213
- this.Zoom(normalizeWheel(event))
339
+ // a zero delta (a horizontal-only scroll) would reset the zoom to 1.0
340
+ let delta = normalizeWheel(event)
341
+ if (delta) {
342
+ this.Zoom(delta, this.Local(event.clientX, event.clientY))
343
+ }
214
344
  //this.$('#zoomslider').slider('option', 'value', this.scale)
215
345
  return cancelEvent(event)
216
346
  }
@@ -219,6 +349,7 @@ class Avispa extends Backbone.View {
219
349
  MiddleClick(event) {}
220
350
  RightClick(event) {}
221
351
  OnContextMenu(event) {}
352
+ OnMessage(msg) {}
222
353
  }
223
354
 
224
355
  exports.Avispa = Avispa
@@ -226,5 +357,6 @@ exports.Position = Position
226
357
  exports.Group = Group
227
358
  exports.Node = Node
228
359
  exports.Link = Link
360
+ exports.Socket = Socket
229
361
  exports.$SVG = $SVG
230
362
  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,28 @@
1
1
  {
2
2
  "name": "@shawcx/avispa",
3
- "version": "1.0.0",
4
- "description": "SVG + Websockets",
3
+ "version": "2.1.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"
10
26
  },
11
27
  "repository": {
12
28
  "type": "git",
@@ -18,12 +34,16 @@
18
34
  "url": "https://github.com/shawcx/avispa/issues"
19
35
  },
20
36
  "homepage": "https://github.com/shawcx/avispa#readme",
21
- "dependencies": {
37
+ "peerDependencies": {
22
38
  "backbone": "^1.4.1",
23
- "jquery": "^3.6.3",
39
+ "jquery": "^3.6.3 || ^4.0.0",
24
40
  "underscore": "^1.13.6"
25
41
  },
26
42
  "devDependencies": {
27
- "sass": "^1.58.2"
43
+ "@playwright/test": "^1.64.1",
44
+ "backbone": "^1.4.1",
45
+ "jquery": "^3.6.3 || ^4.0.0",
46
+ "sass": "^1.106.1",
47
+ "underscore": "^1.13.6"
28
48
  }
29
49
  }
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
  }