Преглед на файлове

Remove D3.js dependency

Lutz Roeder преди 4 години
родител
ревизия
3c8c795130
променени са 9 файла, в които са добавени 522 реда и са изтрити 39 реда
  1. 1 2
      .eslintrc.json
  2. 0 1
      Makefile
  3. 0 1
      package.json
  4. 0 1
      setup.py
  5. 1 1
      source/app.js
  6. 0 8
      source/electron.js
  7. 0 1
      source/index.html
  8. 1 1
      source/index.js
  9. 519 23
      source/view.js

+ 1 - 2
.eslintrc.json

@@ -19,7 +19,6 @@
     },
     "globals": {
         "flatbuffers": "readonly",
-        "protobuf": "readonly",
-        "d3": "readonly"
+        "protobuf": "readonly"
     }
 }

+ 0 - 1
Makefile

@@ -84,7 +84,6 @@ build_web:
 	cp -R ./source/*.ico ./dist/web
 	cp -R ./source/*.png ./dist/web
 	rm -rf ./dist/web/electron.* ./dist/web/app.js
-	cp -R ./node_modules/d3/dist/d3.js ./dist/web
 	cp -R ./node_modules/dagre/dist/dagre.js ./dist/web
 	sed -i "s/0\.0\.0/$$(grep '"version":' package.json -m1 | cut -d\" -f4)/g" ./dist/web/index.html
 

+ 0 - 1
package.json

@@ -17,7 +17,6 @@
         "server": "[ -d node_modules ] || npm install && python setup.py --quiet build && python -c 'import sys, os; sys.path.insert(0, os.path.join(\"dist\", \"lib\")); import netron; netron.main()' $@"
     },
     "dependencies": {
-        "d3": "7.0.0",
         "dagre": "0.8.5",
         "electron-updater": "4.3.9"
     },

+ 0 - 1
setup.py

@@ -10,7 +10,6 @@ import distutils.command.build
 
 node_dependencies = [
     ( 'netron', [
-        'node_modules/d3/dist/d3.js',
         'node_modules/dagre/dist/dagre.js'
       ]
     )

+ 1 - 1
source/app.js

@@ -48,7 +48,7 @@ class Application {
             event.returnValue = {
                 version: electron.app.getVersion(),
                 package: electron.app.isPackaged,
-                zoom: 'd3'
+                zoom: 'drag'
                 // zoom: 'scroll'
             };
         });

+ 0 - 8
source/electron.js

@@ -99,14 +99,6 @@ host.ElectronHost = class {
                     request();
                 });
             }
-        }).then(() => {
-            // d3.js
-            const Module = require('module');
-            var d3 = new Module('', module.main);
-            const location = path.join(path.dirname(__dirname), 'node_modules', 'd3', 'dist', 'd3.js');
-            const source = fs.readFileSync(location, 'utf-8');
-            d3._compile(source, '');
-            global.d3 = d3.exports;
         });
     }
 

+ 0 - 1
source/index.html

@@ -13,7 +13,6 @@
 <link rel="apple-touch-icon" type="image/png" href="icon.png">
 <link rel="apple-touch-icon-precomposed" type="image/png" href="icon.png">
 <link rel="fluid-icon" type="image/png" href="icon.png">
-<script type="text/javascript" src="d3.js"></script>
 <script type="text/javascript" src="dagre.js"></script>
 <script type="text/javascript" src="base.js"></script>
 <script type="text/javascript" src="json.js"></script>

+ 1 - 1
source/index.js

@@ -26,7 +26,7 @@ host.BrowserHost = class {
         this._version = this._meta.version ? this._meta.version[0] : null;
         this._telemetry = this._version && this._version !== '0.0.0';
         this._environment = new Map();
-        this._environment.set('zoom', 'd3');
+        this._environment.set('zoom', 'drag');
         // this._environment.set('zoom', 'scroll');
     }
 

+ 519 - 23
source/view.js

@@ -110,7 +110,7 @@ view.View = class {
                     }
                     break;
                 }
-                case 'd3': {
+                case 'drag': {
                     this._getElementById('toolbar').addEventListener('mousewheel', (e) => {
                         this._preventZoom(e);
                     }, { passive: true });
@@ -232,9 +232,9 @@ view.View = class {
             case 'scroll':
                 this._updateZoom(this._zoom * 1.1);
                 break;
-            case 'd3':
+            case 'drag':
                 if (this._zoom) {
-                    this._zoom.scaleBy(d3.select(this._getElementById('canvas')), 1.2);
+                    this._zoom.scaleBy(1.2);
                 }
                 break;
         }
@@ -245,9 +245,9 @@ view.View = class {
             case 'scroll':
                 this._updateZoom(this._zoom * 0.9);
                 break;
-            case 'd3':
+            case 'drag':
                 if (this._zoom) {
-                    this._zoom.scaleBy(d3.select(this._getElementById('canvas')), 0.8);
+                    this._zoom.scaleBy(0.8);
                 }
                 break;
         }
@@ -258,9 +258,9 @@ view.View = class {
             case 'scroll':
                 this._updateZoom(1);
                 break;
-            case 'd3':
+            case 'drag':
                 if (this._zoom) {
-                    this._zoom.scaleTo(d3.select(this._getElementById('canvas')), 1);
+                    this._zoom.scaleTo(1);
                 }
                 break;
         }
@@ -323,7 +323,7 @@ view.View = class {
         if (selection && selection.length > 0) {
             const graphElement = this._getElementById('graph');
             switch (this._host.environment('zoom')) {
-                case 'd3': {
+                case 'drag': {
                     let x = 0;
                     let y = 0;
                     for (const element of selection) {
@@ -340,7 +340,7 @@ view.View = class {
                     y = y / selection.length;
                     const canvasElement = this._getElementById('canvas');
                     const canvasRect = canvasElement.getBoundingClientRect();
-                    this._zoom.transform(d3.select(canvasElement), d3.zoomIdentity.translate((canvasRect.width / 2) - x, (canvasRect.height / 2) - y));
+                    this._zoom.transform(view.Zoom.identity().translate((canvasRect.width / 2) - x, (canvasRect.height / 2) - y));
                     break;
                 }
                 case 'scroll': {
@@ -500,7 +500,7 @@ view.View = class {
                         canvasElement.style.position = 'static';
                         canvasElement.style.margin = 'auto';
                         break;
-                    case 'd3':
+                    case 'drag':
                         this._zoom = null;
                         canvasElement.style.position = 'absolute';
                         canvasElement.style.margin = '0';
@@ -630,7 +630,7 @@ view.View = class {
                 // https://stackoverflow.com/questions/40887193/d3-js-zoom-is-not-working-with-mousewheel-in-safari
                 const backgroundElement = this._host.document.createElementNS('http://www.w3.org/2000/svg', 'rect');
                 backgroundElement.setAttribute('id', 'background');
-                if (this._host.environment('zoom') === 'd3') {
+                if (this._host.environment('zoom') === 'drag') {
                     backgroundElement.setAttribute('width', '100%');
                     backgroundElement.setAttribute('height', '100%');
                 }
@@ -644,17 +644,13 @@ view.View = class {
 
                 viewGraph.build(this._host.document, originElement);
 
-                let svg = null;
                 switch (this._host.environment('zoom')) {
-                    case 'd3': {
-                        svg = d3.select(canvasElement);
-                        this._zoom = d3.zoom();
-                        this._zoom(svg);
-                        this._zoom.scaleExtent([ 0.1, 1.4 ]);
-                        this._zoom.on('zoom', (event) => {
-                            originElement.setAttribute('transform', event.transform.toString());
+                    case 'drag': {
+                        this._zoom = new view.Zoom(canvasElement, 0.1, 1.4);
+                        this._zoom.on('zoom', (sender, data) => {
+                            originElement.setAttribute('transform', data.transform.toString());
                         });
-                        this._zoom.transform(svg, d3.zoomIdentity);
+                        this._zoom.transform(view.Zoom.identity());
                         break;
                     }
                     case 'scroll': {
@@ -676,7 +672,7 @@ view.View = class {
                     }
 
                     switch (this._host.environment('zoom')) {
-                        case 'd3': {
+                        case 'drag': {
                             const svgSize = canvasElement.getBoundingClientRect();
                             if (elements && elements.length > 0) {
                                 // Center view based on input elements
@@ -696,10 +692,10 @@ view.View = class {
                                 }
                                 const sx = (svgSize.width / (this._showHorizontal ? 4 : 2)) - x;
                                 const sy = (svgSize.height / (this._showHorizontal ? 2 : 4)) - y;
-                                this._zoom.transform(svg, d3.zoomIdentity.translate(sx, sy));
+                                this._zoom.transform(view.Zoom.identity().translate(sx, sy));
                             }
                             else {
-                                this._zoom.transform(svg, d3.zoomIdentity.translate((svgSize.width - viewGraph.graph().width) / 2, (svgSize.height - viewGraph.graph().height) / 2));
+                                this._zoom.transform(view.Zoom.identity().translate((svgSize.width - viewGraph.graph().width) / 2, (svgSize.height - viewGraph.graph().height) / 2));
                             }
                             break;
                         }
@@ -1942,6 +1938,506 @@ view.Error = class extends Error {
     }
 };
 
+view.Zoom = class {
+
+    constructor(node, min, max) {
+        this._scaleExtent = [ min, max ];
+        this._translateExtent = [ [-Infinity, -Infinity], [Infinity, Infinity] ],
+        this._touchStarting = false;
+        this._touchFirst = false;
+        this._touchEnding = false;
+        this._touchDelay = 500;
+        this._wheelDelay = 150;
+        this._clickDistance2 = 0;
+        this._tapDistance = 10;
+        this._events = new Map([ [ 'start', [] ], [ 'zoom', [] ], [ 'end', [] ] ]);
+        this._selection = new view.Zoom.Selection(node);
+        this._selection.node.__zoom = view.Zoom.identity();
+        this._selection.on('wheel.zoom', (event) => this._wheel(event), {passive: false});
+        this._selection.on('mousedown.zoom', (event) => this._mouseDown(event));
+        if (navigator.maxTouchPoints || node.ontouchstart) {
+            this._selection.on('touchstart.zoom', (event) => this._touchStarted(event));
+            this._selection.on('touchmove.zoom', (event) => this._touchMoved(event));
+            this._selection.on('touchend.zoom', (event) => this._touchEnded(event));
+            this._selection.on('touchcancel.zoom', (event) => this._touchEnded(event));
+            node.style.setProperty('-webkit-tap-highlight-color', 'rgba(0,0,0,0)', '');
+        }
+    }
+
+    static identity() {
+        view.Zoom._identity = view.Zoom._identity || new view.Zoom.Transform(1, 0, 0);
+        return view.Zoom._identity;
+    }
+
+    on(event, callback) {
+        if (this._events.has(event)) {
+            if (callback) {
+                this._events.get(event).push(callback);
+            }
+            else {
+                this._events.set([]);
+            }
+        }
+    }
+
+    raise(event, data) {
+        if (this._events.has(event)) {
+            const callbacks = this._events.get(event);
+            for (const callback of callbacks) {
+                callback(this, data);
+            }
+        }
+    }
+
+    transform(transform) {
+        const node = this._selection.node;
+        if (node) {
+            this._gesture(node, arguments)
+                .start()
+                .zoom(null, typeof transform === 'function' ? transform() : transform)
+                .end();
+        }
+    }
+
+    scaleTo(k) {
+        const node = this._selection.node;
+        if (node) {
+            this.transform(() => {
+                const e = this.extent(node);
+                const t0 = node.__zoom;
+                const p0 = this._centroid(e);
+                const p1 = t0.invert(p0);
+                const k1 = typeof k === 'function' ? k() : k;
+                const transform = this.translate(this.scale(t0, k1), p0, p1);
+                return this._constrain(transform, e, this._translateExtent);
+            });
+        }
+    }
+
+    scaleBy(k) {
+        const node = this._selection.node;
+        if (node) {
+            this.scaleTo(() => {
+                const k0 = node.__zoom.k;
+                const k1 = k;
+                return k0 * k1;
+            });
+        }
+    }
+
+    scale(transform, k) {
+        k = Math.max(this._scaleExtent[0], Math.min(this._scaleExtent[1], k));
+        return k === transform.k ? transform : new view.Zoom.Transform(k, transform.x, transform.y);
+    }
+
+    translate(transform, p0, p1) {
+        const x = p0[0] - p1[0] * transform.k, y = p0[1] - p1[1] * transform.k;
+        return x === transform.x && y === transform.y ? transform : new view.Zoom.Transform(transform.k, x, y);
+    }
+
+    pointer(event, node) {
+        while (event.sourceEvent) {
+            event = event.sourceEvent;
+        }
+        if (node === undefined) {
+            node = event.currentTarget;
+        }
+        if (node) {
+            const svg = node.ownerSVGElement || node;
+            if (svg.createSVGPoint) {
+                let point = svg.createSVGPoint();
+                point.x = event.clientX, point.y = event.clientY;
+                point = point.matrixTransform(node.getScreenCTM().inverse());
+                return [point.x, point.y];
+            }
+            if (node.getBoundingClientRect) {
+                const rect = node.getBoundingClientRect();
+                return [event.clientX - rect.left - node.clientLeft, event.clientY - rect.top - node.clientTop];
+            }
+        }
+        return [event.pageX, event.pageY];
+    }
+
+    _filter(event) {
+        return (!event.ctrlKey || event.type === 'wheel') && !event.button;
+    }
+
+    extent(node) {
+        let e = node;
+        if (e instanceof SVGElement) {
+            e = e.ownerSVGElement || e;
+            if (e.hasAttribute('viewBox')) {
+                e = e.viewBox.baseVal;
+                return [[e.x, e.y], [e.x + e.width, e.y + e.height]];
+            }
+            return [[0, 0], [e.width.baseVal.value, e.height.baseVal.value]];
+        }
+        return [[0, 0], [e.clientWidth, e.clientHeight]];
+    }
+
+    _wheelDelta(event) {
+        return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * (event.ctrlKey ? 10 : 1);
+    }
+
+    _constrain(transform, extent, translateExtent) {
+        const dx0 = transform.invertX(extent[0][0]) - translateExtent[0][0];
+        const dx1 = transform.invertX(extent[1][0]) - translateExtent[1][0];
+        const dy0 = transform.invertY(extent[0][1]) - translateExtent[0][1];
+        const dy1 = transform.invertY(extent[1][1]) - translateExtent[1][1];
+        return transform.translate(
+            dx1 > dx0 ? (dx0 + dx1) / 2 : Math.min(0, dx0) || Math.max(0, dx1),
+            dy1 > dy0 ? (dy0 + dy1) / 2 : Math.min(0, dy0) || Math.max(0, dy1)
+        );
+    }
+
+    _centroid(extent) {
+        return [ (+extent[0][0] + +extent[1][0]) / 2, (+extent[0][1] + +extent[1][1]) / 2 ];
+    }
+
+    _gesture(node, clean) {
+        return (!clean && node.__zooming) || new view.Zoom.Gesture(node, this);
+    }
+
+    _stopEvent(event) {
+        event.preventDefault();
+        event.stopImmediatePropagation();
+    }
+
+    _wheel(event) {
+        const currentTarget = event.currentTarget;
+        const wheelidled = (gesture) => {
+            gesture.wheel = null;
+            gesture.end();
+        };
+        if (this._filter(event)) {
+            const gesture = this._gesture(currentTarget);
+            const t = currentTarget.__zoom;
+            const k = Math.max(this._scaleExtent[0], Math.min(this._scaleExtent[1], t.k * Math.pow(2, this._wheelDelta(event))));
+            const p = this.pointer(event);
+            if (gesture.wheel) {
+                if (gesture.mouse[0][0] !== p[0] || gesture.mouse[0][1] !== p[1]) {
+                    gesture.mouse[1] = t.invert(gesture.mouse[0] = p);
+                }
+                clearTimeout(gesture.wheel);
+            }
+            else if (t.k === k) {
+                return;
+            }
+            else {
+                gesture.mouse = [p, t.invert(p)];
+                gesture.start();
+            }
+            event.preventDefault();
+            event.stopImmediatePropagation();
+            gesture.wheel = setTimeout(() => wheelidled(gesture), this._wheelDelay);
+            const transform = this.translate(this.scale(t, k), gesture.mouse[0], gesture.mouse[1]);
+            gesture.zoom('mouse', this._constrain(transform, gesture.extent, this._translateExtent));
+        }
+    }
+
+    _mouseDown(event) {
+        const currentTarget = event.currentTarget;
+        if (this._touchEnding || !this._filter(event)) return;
+        const gesture = this._gesture(currentTarget, true);
+        const selection = new view.Zoom.Selection(event.view)
+            .on('mousemove.zoom', (event) => mousemoved(event), true)
+            .on('mouseup.zoom', (event) => mouseupped(event), true);
+        const p = this.pointer(event, currentTarget);
+        const x0 = event.clientX;
+        const y0 = event.clientY;
+        const root = event.view.document.documentElement;
+        selection.on('dragstart.drag', (event) => this._stopEvent(event), { capture: true, passive: false });
+        if ('onselectstart' in root) {
+            selection.on('selectstart.drag', (event) => this._stopEvent(event), { capture: true, passive: false });
+        }
+        else {
+            root.__noselect = root.style.MozUserSelect;
+            root.style.MozUserSelect = 'none';
+        }
+        event.stopImmediatePropagation();
+        gesture.mouse = [ p, currentTarget.__zoom.invert(p) ];
+        gesture.start();
+        const mousemoved = (event) => {
+            event.preventDefault();
+            event.stopImmediatePropagation();
+            if (!gesture.moved) {
+                const dx = event.clientX - x0, dy = event.clientY - y0;
+                gesture.moved = dx * dx + dy * dy > this._clickDistance2;
+            }
+            const transform = this.translate(gesture.node.__zoom, gesture.mouse[0] = this.pointer(event, currentTarget), gesture.mouse[1]);
+            gesture.zoom('mouse', this._constrain(transform, gesture.extent, this._translateExtent));
+        };
+        const mouseupped = (event) => {
+            selection.on('mousemove.zoom', null);
+            selection.on('mouseup.zoom', null);
+            const root = event.view.document.documentElement;
+            selection.on('dragstart.drag', null);
+            if (gesture.moved) {
+                selection.on('click.drag', (event) => this._stopEvent(event), { capture: true, passive: false });
+                setTimeout(function() { selection.on('click.drag', null); }, 0);
+            }
+            if ('onselectstart' in root) {
+                selection.on('selectstart.drag', null);
+            }
+            else {
+                root.style.MozUserSelect = root.__noselect;
+                delete root.__noselect;
+            }
+            event.preventDefault();
+            event.stopImmediatePropagation();
+            gesture.end();
+        };
+    }
+
+    _touchStarted(event) {
+        const currentTarget = event.currentTarget;
+        if (this._filter(event)) {
+            const touches = event.touches;
+            const gesture = this._gesture(currentTarget, event.changedTouches.length === touches.length);
+            let started;
+            let p;
+            event.stopImmediatePropagation();
+            for (let i = 0; i < touches.length; ++i) {
+                const t = touches[i];
+                p = this.pointer(t, currentTarget);
+                p = [p, currentTarget.__zoom.invert(p), t.identifier];
+                if (!gesture.touch0) {
+                    gesture.touch0 = p;
+                    started = true;
+                    gesture.taps = 1 + !!this._touchStarting;
+                }
+                else if (!gesture.touch1 && gesture.touch0[2] !== p[2]) {
+                    gesture.touch1 = p;
+                    gesture.taps = 0;
+                }
+            }
+            if (this._touchStarting) {
+                this._touchStarting = clearTimeout(this._touchStarting);
+            }
+            if (started) {
+                if (gesture.taps < 2) {
+                    this._touchFirst = p[0];
+                    this._touchStarting = setTimeout(function() { this._touchStarting = null; }, this._touchDelay);
+                }
+                gesture.start();
+            }
+        }
+    }
+
+    _touchMoved(event) {
+        const currentTarget = event.currentTarget;
+        if (!currentTarget.__zooming) return;
+        const gesture = this._gesture(currentTarget);
+        const touches = event.changedTouches;
+        let t, p, l;
+        event.preventDefault();
+        event.stopImmediatePropagation();
+        for (let i = 0; i < touches.length; i++) {
+            t = touches[i], p = this.pointer(t, currentTarget);
+            if (gesture.touch0 && gesture.touch0[2] === t.identifier) {
+                gesture.touch0[0] = p;
+            }
+            else if (gesture.touch1 && gesture.touch1[2] === t.identifier) {
+                gesture.touch1[0] = p;
+            }
+        }
+        t = gesture.node.__zoom;
+        if (gesture.touch1) {
+            const p0 = gesture.touch0[0];
+            const l0 = gesture.touch0[1];
+            const p1 = gesture.touch1[0];
+            const l1 = gesture.touch1[1];
+            let dp, dl;
+            dp = (dp = p1[0] - p0[0]) * dp + (dp = p1[1] - p0[1]) * dp;
+            dl = (dl = l1[0] - l0[0]) * dl + (dl = l1[1] - l0[1]) * dl;
+            t = this.scale(t, Math.sqrt(dp / dl));
+            p = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2];
+            l = [(l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2];
+        }
+        else if (gesture.touch0) {
+            p = gesture.touch0[0], l = gesture.touch0[1];
+        }
+        else {
+            return;
+        }
+        const transform = this.translate(t, p, l);
+        gesture.zoom('touch', this._constrain(transform, gesture.extent, this._translateExtent));
+    }
+
+    _touchEnded(event) {
+        const currentTarget = event.currentTarget;
+        if (!currentTarget.__zooming) return;
+        const gesture = this._gesture(currentTarget);
+        const touches = event.changedTouches;
+        let t;
+        event.stopImmediatePropagation();
+        if (this._touchEnding) {
+            clearTimeout(this._touchEnding);
+        }
+        this._touchEnding = setTimeout(function() { this._touchEnding = null; }, this._touchDelay);
+        for (let i = 0; i < touches.length; i++) {
+            t = touches[i];
+            if (gesture.touch0 && gesture.touch0[2] === t.identifier) {
+                delete gesture.touch0;
+            }
+            else if (gesture.touch1 && gesture.touch1[2] === t.identifier) {
+                delete gesture.touch1;
+            }
+        }
+        if (gesture.touch1 && !gesture.touch0) {
+            gesture.touch0 = gesture.touch1;
+            delete gesture.touch1;
+        }
+        if (gesture.touch0) {
+            gesture.touch0[1] = currentTarget.__zoom.invert(gesture.touch0[0]);
+        }
+        else {
+            gesture.end();
+            if (gesture.taps === 2) {
+                t = this.pointer(t, currentTarget);
+                if (Math.hypot(this._touchFirst[0] - t[0], this._touchFirst[1] - t[1]) < this._tapDistance) {
+                    const selection = new view.Zoom.Selection(currentTarget).on('dblclick.zoom');
+                    if (selection) {
+                        selection.apply(currentTarget, arguments);
+                    }
+                }
+            }
+        }
+    }
+};
+
+view.Zoom.Selection = class {
+
+    constructor(node) {
+        this._node = node;
+    }
+
+    get node() {
+        return this._node;
+    }
+
+    each(callback) {
+        if (this._node) {
+            callback(this._node);
+        }
+        return this;
+    }
+
+    on(name, value, options) {
+        const node = this._node;
+        if (node) {
+            const key = name.split('.');
+            if (value) {
+                node.__on = node.__on || [];
+                const listener = (event) => value.call(node, event);
+                let match = false;
+                for (const handler of node.__on) {
+                    if (handler.type === key[0] && handler.name === key[1]) {
+                        node.removeEventListener(handler.type, handler.listener, handler.options);
+                        node.addEventListener(handler.type, handler.listener = listener, handler.options = options);
+                        handler.value = value;
+                        handler.options = options;
+                        match = true;
+                        break;
+                    }
+                }
+                if (!match) {
+                    node.addEventListener(key[0], listener, options);
+                    node.__on.push({ type: key[0], name: key[1], value: value, listener: listener, options: options });
+                }
+            }
+            else if (node.__on) {
+                node.__on = node.__on.filter((handler) => {
+                    if (handler.type === key[0] && handler.name === key[1]) {
+                        node.removeEventListener(handler.type, handler.listener, handler.options);
+                        return false;
+                    }
+                    return true;
+                });
+                if (node.__on.length === 0) {
+                    delete node.__on;
+                }
+            }
+        }
+        return this;
+    }
+};
+
+view.Zoom.Transform = class {
+
+    constructor(k, x, y) {
+        this.k = k;
+        this.x = x;
+        this.y = y;
+    }
+
+    translate(x, y) {
+        return x === 0 & y === 0 ? this : new view.Zoom.Transform(this.k, this.x + this.k * x, this.y + this.k * y);
+    }
+
+    invert(location) {
+        return [(location[0] - this.x) / this.k, (location[1] - this.y) / this.k];
+    }
+
+    invertX(x) {
+        return (x - this.x) / this.k;
+    }
+
+    invertY(y) {
+        return (y - this.y) / this.k;
+    }
+
+    toString() {
+        return 'translate(' + this.x + ',' + this.y + ') scale(' + this.k + ')';
+    }
+};
+
+view.Zoom.Gesture = class {
+
+    constructor(node, target) {
+        this.node = node;
+        this.active = 0;
+        this.extent = target.extent(node);
+        this.taps = 0;
+        this.target = target;
+    }
+
+    start() {
+        if (++this.active === 1) {
+            this.node.__zooming = this;
+            this.raise('start');
+        }
+        return this;
+    }
+
+    zoom(name, transform) {
+        if (this.mouse && name !== 'mouse') {
+            this.mouse[1] = transform.invert(this.mouse[0]);
+        }
+        if (this.touch0 && name !== 'touch') {
+            this.touch0[1] = transform.invert(this.touch0[0]);
+        }
+        if (this.touch1 && name !== 'touch') {
+            this.touch1[1] = transform.invert(this.touch1[0]);
+        }
+        this.node.__zoom = transform;
+        this.raise('zoom');
+        return this;
+    }
+
+    end() {
+        if (--this.active === 0) {
+            delete this.node.__zooming;
+            this.raise('end');
+        }
+        return this;
+    }
+
+    raise(event) {
+        this.target.raise(event, { transform: this.node.__zoom });
+    }
+};
+
 if (typeof module !== 'undefined' && typeof module.exports === 'object') {
     module.exports.View = view.View;
     module.exports.ModelFactoryService = view.ModelFactoryService;