@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 +118 -5
- package/css/Avispa.css +6 -1
- package/css/Avispa.css.map +1 -1
- package/js/Avispa.js +191 -59
- package/js/BaseObject.js +56 -10
- package/js/Group.js +114 -5
- package/js/Link.js +75 -26
- package/js/Node.js +6 -2
- package/js/Socket.js +134 -0
- package/js/Types.js +1 -1
- package/js/Utils.js +12 -4
- package/package.json +26 -6
- package/scss/Avispa.scss +7 -1
package/README.md
CHANGED
|
@@ -1,11 +1,124 @@
|
|
|
1
1
|
Avispa
|
|
2
2
|
======
|
|
3
3
|
|
|
4
|
-
|
|
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
|
|
7
|
-
general purpose Javascript library for rendering graphs.
|
|
8
|
+
Avispa is Spanish for wasp.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Demo at [https://shawcx.github.io/avispa/](https://shawcx.github.io/avispa/)
|
|
10
11
|
|
|
11
|
-
|
|
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
|
|
19
|
+
font: bold 10pt "Andale Mono", AndaleMono, monospace;
|
|
15
20
|
fill: #000;
|
|
16
21
|
text-anchor: left;
|
|
17
22
|
}
|
package/css/Avispa.css.map
CHANGED
|
@@ -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 }
|
|
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
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'contextmenu.avispa'
|
|
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 =
|
|
76
|
-
this.$pan.y =
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
141
|
+
let point = this.Surface(at)
|
|
142
|
+
this.Scale(scale)
|
|
143
|
+
return this.PanTo(point, at)
|
|
103
144
|
}
|
|
104
145
|
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
109
|
-
point.y =
|
|
110
|
-
point = point.matrixTransform(
|
|
161
|
+
point.x = x
|
|
162
|
+
point.y = y
|
|
163
|
+
point = point.matrixTransform(ctm.inverse())
|
|
164
|
+
return [point.x, point.y]
|
|
165
|
+
}
|
|
111
166
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
127
|
-
case
|
|
191
|
+
switch (event.button) {
|
|
192
|
+
case 0:
|
|
128
193
|
this.LeftDown(event)
|
|
129
194
|
break
|
|
130
|
-
case
|
|
195
|
+
case 1:
|
|
131
196
|
this.MiddleDown(event)
|
|
132
197
|
break
|
|
133
|
-
case
|
|
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.
|
|
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.
|
|
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.
|
|
256
|
+
if (this.dragItem.MiddleClick) this.dragItem.MiddleClick(event)
|
|
181
257
|
break
|
|
182
258
|
case 2:
|
|
183
|
-
this.dragItem.
|
|
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
|
-
|
|
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.
|
|
197
|
-
case
|
|
269
|
+
switch (event.button) {
|
|
270
|
+
case 0:
|
|
198
271
|
this.LeftClick(event)
|
|
199
272
|
break
|
|
200
|
-
case
|
|
273
|
+
case 1:
|
|
201
274
|
this.MiddleClick(event)
|
|
202
275
|
break
|
|
203
|
-
case
|
|
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
|
-
|
|
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
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'contextmenu'
|
|
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
|
|
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
|
-
|
|
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',
|
|
29
|
-
.attr('height',
|
|
30
|
-
.attr('x',
|
|
31
|
-
.attr('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.
|
|
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
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'contextmenu'
|
|
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
|
|
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
|
|
40
|
-
this.right.position
|
|
45
|
+
this.listenTo(this.left.position, 'change', this.render)
|
|
46
|
+
this.listenTo(this.right.position, 'change', this.render)
|
|
41
47
|
|
|
42
|
-
|
|
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 =
|
|
59
|
-
let
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 = -(
|
|
132
|
+
let rot = -(Link.RAD * ang)
|
|
83
133
|
rot += (rot > 0 && rot < 180) ? -90 : 90
|
|
84
134
|
|
|
85
|
-
this.path.attr('d',
|
|
86
|
-
//this.label.attr('x', mx).attr('y', my).attr('transform',
|
|
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
|
|
97
|
-
let from_y =
|
|
98
|
-
let to_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
|
|
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
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
4
|
-
"description": "SVG
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
28
|
+
font: bold 10pt $mono;
|
|
23
29
|
fill: #000;
|
|
24
30
|
text-anchor: left;
|
|
25
31
|
}
|