@shawcx/avispa 2.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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/js/Avispa.js +127 -18
  3. package/package.json +3 -4
package/README.md CHANGED
@@ -83,7 +83,8 @@ $(() => {
83
83
 
84
84
  On screen:
85
85
 
86
- - Drag the background to pan, scroll to zoom, middle-click to recenter.
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.
87
88
  - Drag a node or group to move it; a group's children come along. A node stays
88
89
  inside its group, and a nested group dragged past its parent's edge grows it.
89
90
  - Drag a group's bottom-right corner to resize it, down to what its contents need.
package/js/Avispa.js CHANGED
@@ -13,6 +13,8 @@ const Link = require('./Link.js')
13
13
  const Socket = require('./Socket.js')
14
14
 
15
15
  class Avispa extends Backbone.View {
16
+ static TOUCH_EVENTS = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel']
17
+
16
18
  events() {
17
19
  return {
18
20
  'pointerdown.avispa' : 'OnMouseDown',
@@ -32,7 +34,7 @@ class Avispa extends Backbone.View {
32
34
  this.$el.css({'touch-action': 'none', 'user-select': 'none'})
33
35
 
34
36
  _.bindAll(this, 'render',
35
- 'OnMouseDown', 'OnMouseMove', 'OnMouseUp', 'OnMouseWheel', 'OnContextMenu')
37
+ 'OnMouseDown', 'OnMouseMove', 'OnMouseUp', 'OnMouseWheel', 'OnContextMenu', 'OnTouch')
36
38
 
37
39
  this.scale = 1.0
38
40
  this.links = {}
@@ -40,6 +42,15 @@ class Avispa extends Backbone.View {
40
42
  this.dragItem = null
41
43
  this.arrow = null
42
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
+
43
54
  this.position = {
44
55
  x: 0,
45
56
  y: 0
@@ -61,10 +72,10 @@ class Avispa extends Backbone.View {
61
72
  this.$objects = this.$el.find('g.objects')
62
73
  this.$labels = this.$el.find('g.labels')
63
74
 
64
- this.$pan.x = parseInt(window.innerWidth / 2)
65
- this.$pan.y = parseInt(window.innerHeight / 2)
75
+ this.$pan.x = 0
76
+ this.$pan.y = 0
66
77
 
67
- this.Pan(0,0)
78
+ this.Center()
68
79
 
69
80
  // opt-in WebSocket transport: `socket` may be a path string or a
70
81
  // Socket options object. Incoming messages are routed to OnMessage
@@ -86,6 +97,9 @@ class Avispa extends Backbone.View {
86
97
  if (this.socket) {
87
98
  this.socket.close()
88
99
  }
100
+ for (const type of Avispa.TOUCH_EVENTS) {
101
+ this.el.removeEventListener(type, this.OnTouch, true)
102
+ }
89
103
  return super.remove()
90
104
  }
91
105
 
@@ -105,25 +119,66 @@ class Avispa extends Backbone.View {
105
119
  return this
106
120
  }
107
121
 
108
- Zoom(delta) {
109
- let scale = (delta == 0) ? 1.0 : this.scale + delta * this.zoom.step
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) {
110
137
  scale = Math.min(this.zoom.max, Math.max(this.zoom.min, scale))
138
+ if (!at) {
139
+ return this.Scale(scale)
140
+ }
141
+ let point = this.Surface(at)
111
142
  this.Scale(scale)
112
- return this
143
+ return this.PanTo(point, at)
113
144
  }
114
145
 
115
- Point(event) {
116
- // 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
+ }
117
160
  let point = this.el.createSVGPoint()
118
- point.x = event.clientX
119
- point.y = event.clientY
120
- 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
+ }
121
166
 
122
- // account for the current pan and scale
123
- point.x = parseInt((point.x - this.$pan.x) / this.scale)
124
- 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
+ }
125
172
 
126
- 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)]
127
182
  }
128
183
 
129
184
  OnMouseDown(event) {
@@ -156,12 +211,19 @@ class Avispa extends Backbone.View {
156
211
  }
157
212
 
158
213
  MiddleDown(event) {
159
- this.Pan(-this.$pan.x + window.innerWidth / 2, -this.$pan.y + window.innerHeight / 2)
214
+ this.Center()
160
215
  this.Zoom(0)
161
216
  //this.$('#zoomslider').slider('option', 'value', 1)
162
217
  }
163
218
 
164
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
+
165
227
  // drag the entire scene around
166
228
  if (this.offset) {
167
229
  this.Pan(event.clientX - this.offset[0], event.clientY - this.offset[1])
@@ -226,11 +288,58 @@ class Avispa extends Backbone.View {
226
288
  this.dragItem = null
227
289
  }
228
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
+
229
338
  OnMouseWheel(event) {
230
339
  // a zero delta (a horizontal-only scroll) would reset the zoom to 1.0
231
340
  let delta = normalizeWheel(event)
232
341
  if (delta) {
233
- this.Zoom(delta)
342
+ this.Zoom(delta, this.Local(event.clientX, event.clientY))
234
343
  }
235
344
  //this.$('#zoomslider').slider('option', 'value', this.scale)
236
345
  return cancelEvent(event)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawcx/avispa",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Draw node/link graphs in SVG and drag, pan, zoom, and collapse them; Backbone views with an optional WebSocket feed",
5
5
  "keywords": [
6
6
  "svg",
@@ -22,8 +22,7 @@
22
22
  "build": "npx sass scss/Avispa.scss css/Avispa.css",
23
23
  "example": "npm --prefix example install && npm --prefix example start",
24
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/"
25
+ "pretest": "npm --prefix example install --no-audit --no-fund && npm --prefix example run build"
27
26
  },
28
27
  "repository": {
29
28
  "type": "git",
@@ -44,7 +43,7 @@
44
43
  "@playwright/test": "^1.64.1",
45
44
  "backbone": "^1.4.1",
46
45
  "jquery": "^3.6.3 || ^4.0.0",
47
- "sass": "^1.58.2",
46
+ "sass": "^1.106.1",
48
47
  "underscore": "^1.13.6"
49
48
  }
50
49
  }