From b78c9ce34a59e1532b83ec41fe67baa309a61edf Mon Sep 17 00:00:00 2001
From: Toby Milner-Gulland <tmgulland@movingbrands.com>
Date: Mon, 13 Apr 2020 22:25:41 +0100
Subject: [PATCH] working on eventhandlers for different modes

---
 .DS_Store                                     | Bin 0 -> 6148 bytes
 app/package-lock.json                         | 110 +++++++-
 app/package.json                              |   1 +
 app/src/experimental/PanZoomContainer.vue     | 254 ++++++------------
 app/src/experimental/geometry.js              |  30 ---
 .../experimental/layers/SelectionLayer.vue    |  87 ++++++
 .../experimental/makePassiveEventOption.js    |  19 --
 .../experimental/{modes.js => modes/index.js} |   2 +-
 app/src/experimental/modes/select.js          |   5 +
 app/src/experimental/uiStore.js               |  13 +-
 app/src/experimental/utils/canvas.js          |   4 +-
 app/src/experimental/utils/dom.js             |   5 +-
 app/src/experimental/utils/nodes.js           |  14 +-
 app/src/experimental/utils/numbers.js         |  48 ++++
 app/src/experimental/utils/svg.js             |   6 +-
 app/src/experimental/utils/view.js            | 134 +++++++++
 app/src/views/Sandbox.vue                     |  34 ++-
 17 files changed, 519 insertions(+), 247 deletions(-)
 create mode 100644 .DS_Store
 delete mode 100644 app/src/experimental/geometry.js
 create mode 100644 app/src/experimental/layers/SelectionLayer.vue
 delete mode 100644 app/src/experimental/makePassiveEventOption.js
 rename app/src/experimental/{modes.js => modes/index.js} (97%)
 create mode 100644 app/src/experimental/modes/select.js
 create mode 100644 app/src/experimental/utils/view.js

diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..fadd49af93a07740151b4698fdea46e28fdbf625
GIT binary patch
literal 6148
zcmeH~I}XA?3`A{6fkcy%avKi74OR$Fzy<h~kw8N9dvwO1hCq!XG?whQ_QpywMK%@@
z-9E1ykzPa=aHFg(j7*Ww<s^5xUmus@e7g-+a+@Ws0PkhApW6f#paN8Y3Qz$mFd+r<
zAYUve^h|sdDnJFMp@4lK3fx$eE$E*P1Rnvw4rMp2eU<==6~LNoK~!KGtzfjOk0Dm~
zcCh4iHQ9pEE}FxK=AG517??)8Xh8zg>R_M(RA8jQJo4Vo|2_QM{6A`8N(HFEpDCc-
z?y%e9rSfe3cs;8hvuf)G2mNw{x1Rtcb`-DRZrCrj0Bf=ZQGxMCz-3^d0zXyY1qdw>
AZU6uP

literal 0
HcmV?d00001

diff --git a/app/package-lock.json b/app/package-lock.json
index 3702440..596be55 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "nodenogg.in",
-  "version": "0.1.10",
+  "version": "0.1.11",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -2060,6 +2060,14 @@
       "integrity": "sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==",
       "dev": true
     },
+    "affine-hull": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/affine-hull/-/affine-hull-1.0.0.tgz",
+      "integrity": "sha1-dj/x040GPOt+Jy8X7k17vK+QXF0=",
+      "requires": {
+        "robust-orientation": "^1.1.3"
+      }
+    },
     "aggregate-error": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz",
@@ -2183,6 +2191,11 @@
       "integrity": "sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg==",
       "dev": true
     },
+    "area-polygon": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/area-polygon/-/area-polygon-1.0.1.tgz",
+      "integrity": "sha1-gCp1QIOL5Bi4rhzZnB5Mu7ktbRs="
+    },
     "argparse": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -2530,6 +2543,11 @@
         "file-uri-to-path": "1.0.0"
       }
     },
+    "bit-twiddle": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz",
+      "integrity": "sha1-DGwfq+KyPRcXPZpht7cJPrnhdp4="
+    },
     "bluebird": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -3522,6 +3540,16 @@
         }
       }
     },
+    "convex-hull": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/convex-hull/-/convex-hull-1.0.3.tgz",
+      "integrity": "sha1-IKOqbOh/St6i/30XlxyfwcZ+H/8=",
+      "requires": {
+        "affine-hull": "^1.0.0",
+        "incremental-convex-hull": "^1.0.1",
+        "monotone-convex-hull-2d": "^1.0.1"
+      }
+    },
     "cookie": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
@@ -7031,6 +7059,15 @@
       "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
       "dev": true
     },
+    "incremental-convex-hull": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/incremental-convex-hull/-/incremental-convex-hull-1.0.1.tgz",
+      "integrity": "sha1-UUKMFMudmmFEv+abKFH7N3M0vh4=",
+      "requires": {
+        "robust-orientation": "^1.1.2",
+        "simplicial-complex": "^1.0.0"
+      }
+    },
     "indent-string": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -7171,6 +7208,15 @@
         }
       }
     },
+    "interactive-shape-recognition": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/interactive-shape-recognition/-/interactive-shape-recognition-1.0.1.tgz",
+      "integrity": "sha512-BQZajF3oVJ8l38UbEuFWV2G6kKRvlvZvahiXRY2GN8tE/Ch6iIkKKefPVRbTi///JIwZuy5JcEuxZPEoProhTg==",
+      "requires": {
+        "area-polygon": "^1.0.1",
+        "convex-hull": "^1.0.3"
+      }
+    },
     "internal-ip": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-4.3.0.tgz",
@@ -8512,6 +8558,14 @@
         }
       }
     },
+    "monotone-convex-hull-2d": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/monotone-convex-hull-2d/-/monotone-convex-hull-2d-1.0.1.tgz",
+      "integrity": "sha1-R/Xa6t88Sv03dkuqGqh4ekDu4Iw=",
+      "requires": {
+        "robust-orientation": "^1.1.3"
+      }
+    },
     "move-concurrently": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -10802,6 +10856,36 @@
         "inherits": "^2.0.1"
       }
     },
+    "robust-orientation": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/robust-orientation/-/robust-orientation-1.1.3.tgz",
+      "integrity": "sha1-2v9bANO+TmByLw6cAVbvln8cIEk=",
+      "requires": {
+        "robust-scale": "^1.0.2",
+        "robust-subtract": "^1.0.0",
+        "robust-sum": "^1.0.0",
+        "two-product": "^1.0.2"
+      }
+    },
+    "robust-scale": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/robust-scale/-/robust-scale-1.0.2.tgz",
+      "integrity": "sha1-d1Ey7QlULQKOWLLMecBikLz3jDI=",
+      "requires": {
+        "two-product": "^1.0.2",
+        "two-sum": "^1.0.0"
+      }
+    },
+    "robust-subtract": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/robust-subtract/-/robust-subtract-1.0.0.tgz",
+      "integrity": "sha1-4LFk4e2LpOOl3aRaEgODSNvtPpo="
+    },
+    "robust-sum": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/robust-sum/-/robust-sum-1.0.0.tgz",
+      "integrity": "sha1-FmRuUlKStNJdgnV6KGlV4Lv6U9k="
+    },
     "run-async": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.0.tgz",
@@ -11107,6 +11191,15 @@
         }
       }
     },
+    "simplicial-complex": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/simplicial-complex/-/simplicial-complex-1.0.0.tgz",
+      "integrity": "sha1-bDOk7Wn81Nkbe8rdOzC2NoPq4kE=",
+      "requires": {
+        "bit-twiddle": "^1.0.0",
+        "union-find": "^1.0.0"
+      }
+    },
     "slash": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
@@ -12108,6 +12201,16 @@
       "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
       "dev": true
     },
+    "two-product": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/two-product/-/two-product-1.0.2.tgz",
+      "integrity": "sha1-Z9ldSyV6kh4stL16+VEfkIhSLqo="
+    },
+    "two-sum": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/two-sum/-/two-sum-1.0.0.tgz",
+      "integrity": "sha1-MdPzIjnk9zHsqd+RVeKyl/AIq2Q="
+    },
     "type-check": {
       "version": "0.3.2",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@@ -12191,6 +12294,11 @@
       "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==",
       "dev": true
     },
+    "union-find": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/union-find/-/union-find-1.0.2.tgz",
+      "integrity": "sha1-KSusQV5q06iVNdI3AQ20pTYoTlg="
+    },
     "union-value": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
diff --git a/app/package.json b/app/package.json
index 3111bc2..a6f011d 100644
--- a/app/package.json
+++ b/app/package.json
@@ -10,6 +10,7 @@
   },
   "dependencies": {
     "core-js": "^3.6.4",
+    "interactive-shape-recognition": "^1.0.1",
     "marked": "^0.8.2",
     "pouchdb": "^7.2.1",
     "pouchdb-find": "^7.2.1",
diff --git a/app/src/experimental/PanZoomContainer.vue b/app/src/experimental/PanZoomContainer.vue
index a5d4be7..adc5007 100644
--- a/app/src/experimental/PanZoomContainer.vue
+++ b/app/src/experimental/PanZoomContainer.vue
@@ -15,6 +15,7 @@
   transform-origin: 0 0;
   background-size: 40px 40px;
   background-color: rgb(245, 245, 245);
+  border: 1px solid rgba(0, 0, 0, 0.25);
   background-image: radial-gradient(
     circle,
     rgba(0, 0, 0, 0.5) 1px,
@@ -37,7 +38,7 @@
     v-on:wheel.capture="onWheel"
     v-on:touchstart.passive="onTouchStart"
     v-on:mousedown="onMouseDown"
-    v-on:touchmove.passive="onTouchMove"
+    v-on:touchmove="onTouchMove"
     v-on:mousemove="onMouseMove"
     v-on:touchend.passive.capture="onTouchEnd"
     v-on:mouseup.passive.capture="onMouseUp"
@@ -46,26 +47,27 @@
   >
     <div
       ref="innerContainer"
-      v-bind:class="{ inner: true, active: pan }"
+      v-bind:class="{ inner: true, active: true }"
       v-bind:style="{
         width: `${width}px`,
         height: `${height}px`,
         transform: `translate(${translation.x}px, ${translation.y}px) scale(${scale})`
       }"
     >
+      {{ JSON.stringify(interaction) }}
       <slot />
     </div>
   </div>
 </template>
 <script>
+import { mapState } from 'vuex'
+import { constrainTranslation } from '@/experimental/utils/numbers'
 import {
-  clamp,
-  midpoint,
-  touchPt,
-  touchDistance,
-  coordChange
-} from './geometry'
-// import { mapRange } from '@/experimental/utils/numbers'
+  getNormalisedInteraction,
+  changeViewFromWheelEvent,
+  changeViewFromMultiTouchEvent
+} from '@/experimental/utils/view'
+
 export default {
   name: 'map-interaction',
   data() {
@@ -73,6 +75,11 @@ export default {
       shouldPreventTouchEndDefault: false
     }
   },
+  computed: {
+    ...mapState({
+      interaction: state => state.ui.interaction
+    })
+  },
   props: {
     translationBounds: {
       type: Object,
@@ -84,14 +91,6 @@ export default {
     scale: Number,
     width: Number,
     height: Number,
-    pan: {
-      type: Boolean,
-      default: true
-    },
-    zoom: {
-      type: Boolean,
-      default: true
-    },
     minScale: {
       type: Number,
       default: 0.3
@@ -108,60 +107,63 @@ export default {
         this.shouldPreventTouchEndDefault = false
       }
 
-      const rect = this.$refs.container.getBoundingClientRect()
-
-      const x = e.clientX - parseInt(rect.left, 10)
-      const y = e.clientY - parseInt(rect.top, 10)
-
-      const result = {
-        relative: { x, y },
-        canvas: {
-          x: parseInt((x + -this.translation.x) / this.scale, 10),
-          y: parseInt((y + -this.translation.y) / this.scale, 10)
-        }
-      }
-      console.log(result)
+      const { relativePoint, boardPoint } = getNormalisedInteraction(
+        this.$refs.container,
+        e,
+        this.translation,
+        this.scale
+      )
+      console.log(relativePoint, boardPoint)
     },
+    reset() {},
     onMouseDown(e) {
       e.preventDefault()
       this.setPointerState([e])
     },
-    onTouchStart(e) {
+    onMouseMove(e) {
+      if (!this.interaction.origin) {
+        return
+      }
       e.preventDefault()
-      this.setPointerState(e.touches)
+      this.handleDrag(e)
     },
     onMouseUp() {
       this.setPointerState([])
     },
-    onTouchEnd(e) {
-      this.setPointerState(e.touches)
-    },
-    onMouseMove(e) {
-      if (!this.startPointerInfo || !this.pan) {
-        return
-      }
-
+    onTouchStart(e) {
       e.preventDefault()
-      this.onDrag(e)
+      this.setPointerState(e.touches)
     },
     onTouchMove(e) {
-      if (!this.startPointerInfo) {
+      if (!this.interaction.origin) {
         return
       }
-
+  console.log('touch')
       e.preventDefault()
       const isPinchAction =
-        e.touches.length == 2 && this.startPointerInfo.pointers.length > 1
+        e.touches.length == 2 && this.interaction.origin.points.length > 1
 
-      if (isPinchAction && this.zoom) {
-        this.scaleFromMultiTouch(e)
-      } else if (e.touches.length === 1 && this.startPointerInfo && this.pan) {
-        this.onDrag(e.touches[0])
+      if (isPinchAction) {
+        this.handleMultiTouch(e)
+      } else if (e.touches.length === 1 && this.interaction.origin) {
+        this.handleDrag(e.touches[0])
       }
     },
-    onDrag(pointer) {
-      const { translation, pointers } = this.startPointerInfo
-      const startPointer = pointers[0]
+    onTouchEnd(e) {
+      this.setPointerState(e.touches)
+    },
+    onWheel(e) {
+      e.preventDefault()
+      e.stopPropagation()
+
+        console.log(e)
+
+      this.handleWheel(e)
+    },
+
+    handleDrag(pointer) {
+      const { translation, points } = this.interaction.origin
+      const startPointer = points[0]
       const dragX = pointer.clientX - startPointer.clientX
       const dragY = pointer.clientY - startPointer.clientY
       const newTranslation = {
@@ -169,155 +171,51 @@ export default {
         y: translation.y + dragY
       }
 
+      console.log(dragX, dragY)
+
       this.$store.commit(
         'ui/setTranslation',
-        this.clampTranslation(newTranslation)
+        constrainTranslation(newTranslation, this.translationBounds)
       )
       this.shouldPreventTouchEndDefault = true
     },
-    onWheel(e) {
-      if (!this.zoom) {
+    setPointerState(points) {
+      if (!points || points.length === 0) {
+        this.$store.commit('ui/resetOrigin')
         return
       }
 
-      e.preventDefault()
-      e.stopPropagation()
-      const scaleChange = 2 ** (e.deltaY * 0.002)
-      const newScale = clamp(
-        this.minScale,
-        this.scale + (1 - scaleChange),
-        this.maxScale
-      )
-      const mousePos = this.clientPosToTranslatedPos({
-        x: e.clientX,
-        y: e.clientY
-      })
-      this.scaleFromPoint(newScale, mousePos)
-    },
-    setPointerState(pointers) {
-      if (!pointers || pointers.length === 0) {
-        this.startPointerInfo = undefined
-        return
-      }
-
-      this.startPointerInfo = {
-        pointers,
+      this.$store.commit('ui/setOrigin', {
+        points,
         scale: this.scale,
         translation: this.translation
-      }
-    },
-    clampTranslation(desiredTranslation = this) {
-      const { x, y } = desiredTranslation
-      let { xMax, xMin, yMax, yMin } = this.translationBounds
-      xMin = xMin != undefined ? xMin : -Infinity
-      yMin = yMin != undefined ? yMin : -Infinity
-      xMax = xMax != undefined ? xMax : Infinity
-      yMax = yMax != undefined ? yMax : Infinity
-      return {
-        x: clamp(xMin, x, xMax),
-        y: clamp(yMin, y, yMax)
-      }
-    },
-    translatedOrigin(translation = this.translation) {
-      const clientOffset = this.$refs.container.getBoundingClientRect()
-      return {
-        x: clientOffset.left + translation.x,
-        y: clientOffset.top + translation.y
-      }
-    },
-    clientPosToTranslatedPos({ x, y }, translation = this.translation) {
-      const origin = this.translatedOrigin(translation)
-      return {
-        x: x - origin.x,
-        y: y - origin.y
-      }
+      })
     },
-    scaleFromPoint(newScale, focalPt) {
-      const { translation, scale } = this
-      const scaleRatio = newScale / (scale != 0 ? scale : 1)
-      const focalPtDelta = {
-        x: coordChange(focalPt.x, scaleRatio),
-        y: coordChange(focalPt.y, scaleRatio)
-      }
-      const newTranslation = {
-        x: translation.x - focalPtDelta.x,
-        y: translation.y - focalPtDelta.y
-      }
+    handleWheel(e) {
+      const [newScale, newTranslation] = changeViewFromWheelEvent(
+        e,
+        this.$refs.container,
+        this
+      )
       this.$store.commit('ui/setScale', newScale)
-
       this.$store.commit(
         'ui/setTranslation',
-        this.clampTranslation(newTranslation)
+        constrainTranslation(newTranslation, this.translationBounds)
       )
     },
-    scaleFromMultiTouch(e) {
-      const startTouches = this.startPointerInfo.pointers
-      const newTouches = e.touches
-      // calculate new scale
-      const dist0 = touchDistance(startTouches[0], startTouches[1])
-      const dist1 = touchDistance(newTouches[0], newTouches[1])
-      const scaleChange = dist1 / dist0
-      const startScale = this.startPointerInfo.scale
-      const targetScale = startScale + (scaleChange - 1) * startScale
-      const newScale = clamp(this.minScale, targetScale, this.maxScale)
-      // calculate mid points
-      const newMidPoint = midpoint(
-        touchPt(newTouches[0]),
-        touchPt(newTouches[1])
-      )
-      const startMidpoint = midpoint(
-        touchPt(startTouches[0]),
-        touchPt(startTouches[1])
+    handleMultiTouch(e) {
+      const [newTranslation, newScale] = changeViewFromMultiTouchEvent(
+        this.interaction.origin.points,
+        e.touches,
+        this.$refs.container,
+        this
       )
-      const dragDelta = {
-        x: newMidPoint.x - startMidpoint.x,
-        y: newMidPoint.y - startMidpoint.y
-      }
-      const scaleRatio = newScale / startScale
-      const focalPt = this.clientPosToTranslatedPos(
-        startMidpoint,
-        this.startPointerInfo.translation
-      )
-      const focalPtDelta = {
-        x: coordChange(focalPt.x, scaleRatio),
-        y: coordChange(focalPt.y, scaleRatio)
-      }
-      const newTranslation = {
-        x: this.startPointerInfo.translation.x - focalPtDelta.x + dragDelta.x,
-        y: this.startPointerInfo.translation.y - focalPtDelta.y + dragDelta.y
-      }
-
       this.$store.commit('ui/setScale', newScale)
-
       this.$store.commit(
         'ui/setTranslation',
-        this.clampTranslation(newTranslation)
+        constrainTranslation(newTranslation, this.translationBounds)
       )
-    },
-    discreteScaleStepSize() {
-      const { minScale, maxScale } = this
-      const delta = Math.abs(maxScale - minScale)
-      return delta / 10
-    },
-    changeScale(delta) {
-      const targetScale = this.scale + delta
-      const { minScale, maxScale } = this
-      const scale = clamp(minScale, targetScale, maxScale)
-      const rect = this.$refs.container.getBoundingClientRect()
-      const x = rect.left + rect.width / 2
-      const y = rect.top + rect.height / 2
-      const focalPoint = this.clientPosToTranslatedPos({
-        x,
-        y
-      })
-      this.scaleFromPoint(scale, focalPoint)
     }
-  },
-  mounted() {
-    this.containerNode = this.$refs.container
-  },
-  created() {
-    this.startPointerInfo = undefined
   }
 }
 </script>
diff --git a/app/src/experimental/geometry.js b/app/src/experimental/geometry.js
deleted file mode 100644
index 45ab1e0..0000000
--- a/app/src/experimental/geometry.js
+++ /dev/null
@@ -1,30 +0,0 @@
-export const clamp = (min, value, max) => {
-  return Math.max(min, Math.min(value, max))
-}
-
-export const distance = (p1, p2) => {
-  const dx = p1.x - p2.x
-  const dy = p1.y - p2.y
-  return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2))
-}
-
-export const midpoint = (p1, p2) => {
-  return {
-    x: (p1.x + p2.x) / 2,
-    y: (p1.y + p2.y) / 2
-  }
-}
-
-export const touchPt = touch => {
-  return { x: touch.clientX, y: touch.clientY }
-}
-
-export const touchDistance = (t0, t1) => {
-  const p0 = touchPt(t0)
-  const p1 = touchPt(t1)
-  return distance(p0, p1)
-}
-
-export const coordChange = (coordinate, scaleRatio) => {
-  return scaleRatio * coordinate - coordinate
-}
diff --git a/app/src/experimental/layers/SelectionLayer.vue b/app/src/experimental/layers/SelectionLayer.vue
new file mode 100644
index 0000000..992cf29
--- /dev/null
+++ b/app/src/experimental/layers/SelectionLayer.vue
@@ -0,0 +1,87 @@
+<template>
+  <canvas ref="canvas" v-bind:width="width" v-bind:height="height"></canvas>
+</template>
+
+<script>
+import { palette } from '@/experimental/constants/color'
+
+import { clear } from '@/experimental/utils/canvas'
+
+export default {
+  props: {
+    width: {
+      type: Number
+    },
+    height: {
+      type: Number
+    },
+    shape: {
+      type: Object
+    }
+  },
+  mounted() {
+    this.canvas = this.$refs.canvas
+    this.context = this.canvas.getContext('2d')
+    this.draw()
+  },
+  data() {
+    return {
+      canvas: {},
+      context: {},
+      color: palette.blue.dark
+    }
+  },
+  // Watch the props for changes and, if necessary, redraw canvas
+  watch: {
+    width() {
+      this.draw()
+    },
+    height() {
+      this.draw()
+    },
+    shape() {
+      this.draw()
+    },
+    selected() {
+      this.draw()
+    }
+  },
+  methods: {
+    /**
+     * Clears the context, renders selection
+     **/
+    draw() {
+      clear(this.context, this.canvas.width, this.canvas.height)
+      this.renderSelection()
+    },
+
+    renderSelection() {
+      this.context.globalAlpha = 0.75
+      if (this.shape) {
+        this.context.globalAlpha = 0.5
+        const { x, y, width, height } = this.shape
+        this.context.fillStyle = this.color
+        this.context.setTransform(1, 0, 0, 1, x, y)
+        this.context.beginPath()
+
+        this.context.fillRect(0, 0, width, height)
+        this.context.closePath()
+
+        this.context.setTransform(1, 0, 0, 1, 0, 0)
+      }
+    }
+  }
+}
+</script>
+<style scoped>
+canvas {
+  z-index: 3;
+  pointer-events: none;
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  /* opacity: 0.5; */
+}
+</style>
diff --git a/app/src/experimental/makePassiveEventOption.js b/app/src/experimental/makePassiveEventOption.js
deleted file mode 100644
index 8b32c88..0000000
--- a/app/src/experimental/makePassiveEventOption.js
+++ /dev/null
@@ -1,19 +0,0 @@
-let passiveSupported = false
-try {
-  const options = {
-    /* eslint-disable getter-return */
-    get passive() {
-      passiveSupported = true
-    }
-  }
-  window.addEventListener('test', options, options)
-  window.removeEventListener('test', options, options)
-} catch {
-  passiveSupported = false
-}
-
-const makePassiveEventOption = passive => {
-  return passiveSupported ? { passive } : passive
-}
-
-export default makePassiveEventOption
diff --git a/app/src/experimental/modes.js b/app/src/experimental/modes/index.js
similarity index 97%
rename from app/src/experimental/modes.js
rename to app/src/experimental/modes/index.js
index e87115e..88ccc2c 100644
--- a/app/src/experimental/modes.js
+++ b/app/src/experimental/modes/index.js
@@ -2,7 +2,7 @@ export const select = {
   name: 'select',
   view: {
     pan: false,
-    zoom: true
+    zoom: false
   },
   icon: 'select',
   cursor: 'initial',
diff --git a/app/src/experimental/modes/select.js b/app/src/experimental/modes/select.js
new file mode 100644
index 0000000..72ea587
--- /dev/null
+++ b/app/src/experimental/modes/select.js
@@ -0,0 +1,5 @@
+export default {
+    mixins: {
+        
+    }
+}
\ No newline at end of file
diff --git a/app/src/experimental/uiStore.js b/app/src/experimental/uiStore.js
index 0726226..bbf903b 100644
--- a/app/src/experimental/uiStore.js
+++ b/app/src/experimental/uiStore.js
@@ -6,7 +6,12 @@ const store = {
     interaction: {
       active: false,
       origin: null,
-      shape: null
+      shape: {
+        x: 10,
+        y: 10,
+        width: 100,
+        height: 200
+      }
     },
     selection: {
       links: [],
@@ -36,6 +41,12 @@ const store = {
     }
   },
   mutations: {
+    setOrigin(state, origin) {
+      state.interaction.origin = origin
+    },
+    resetOrigin(state) {
+      state.interaction.origin = null
+    },
     setMode(state, mode) {
       if (allModes[mode]) {
         state.mode = mode
diff --git a/app/src/experimental/utils/canvas.js b/app/src/experimental/utils/canvas.js
index c6dc3d5..48db8b7 100644
--- a/app/src/experimental/utils/canvas.js
+++ b/app/src/experimental/utils/canvas.js
@@ -1,6 +1,6 @@
 import * as win from '@/experimental/constants/window'
 import { generateLinkHandles } from '@/experimental/utils/nodes'
-import { mapRange, distance, angleBetween } from '@/experimental/utils/numbers'
+import { mapRange, distanceBetween, angleBetween } from '@/experimental/utils/numbers'
 
 //////////////////////////////////////////////////////////////////////
 // CANVAS UTILITIES
@@ -23,7 +23,7 @@ export const drawLinkBezierCurve = (context, from, to, tension) => {
   // This is a simple way to adjust the link tension depending
   // on the distance the link covers
   const adjustedTension = mapRange(
-    distance(fromHandle, toHandle),
+    distanceBetween(fromHandle, toHandle),
     0,
     win.width,
     tension * 0.01,
diff --git a/app/src/experimental/utils/dom.js b/app/src/experimental/utils/dom.js
index f74ad77..ed19558 100644
--- a/app/src/experimental/utils/dom.js
+++ b/app/src/experimental/utils/dom.js
@@ -5,7 +5,6 @@ import { isFunction } from '@/experimental/utils/helpers'
 //////////////////////////////////////////////////////////////////////
 
 /**
- * !! INCOMPLETE !!
  * Normalises mouse/touch interaction events
  *
  * @param {HTMLElement} target - base element for event
@@ -19,11 +18,11 @@ export const getInteractionPoint = (target, event) => {
   return {
     x: parseInt(
       ((event.clientX - rect.left) / (rect.right - rect.left)) *
-        target.offsetWidth
+      target.offsetWidth
     ),
     y: parseInt(
       ((event.clientY - rect.top) / (rect.bottom - rect.top)) *
-        target.offsetHeight
+      target.offsetHeight
     )
   }
 }
diff --git a/app/src/experimental/utils/nodes.js b/app/src/experimental/utils/nodes.js
index 11ff05b..4e09101 100644
--- a/app/src/experimental/utils/nodes.js
+++ b/app/src/experimental/utils/nodes.js
@@ -186,12 +186,16 @@ export const generateAreaFromNodes = nodes => {
 
 /**
  * Generates a node shape from two given points
+ * 
+ * @param {Point} origin
+ * @param {Point} target
+ * @return {Node} 
  *  */
-export const generateNode = (origin, point) => {
-  const minusX = origin.x > point.x
-  const minusY = origin.y > point.y
-  const width = Math.abs(point.x - origin.x)
-  const height = Math.abs(point.y - origin.y)
+export const generateNode = (origin, target) => {
+  const minusX = origin.x > target.x
+  const minusY = origin.y > target.y
+  const width = Math.abs(target.x - origin.x)
+  const height = Math.abs(target.y - origin.y)
 
   return {
     x: minusX ? origin.x - width : origin.x,
diff --git a/app/src/experimental/utils/numbers.js b/app/src/experimental/utils/numbers.js
index 2f5843d..cdb7134 100644
--- a/app/src/experimental/utils/numbers.js
+++ b/app/src/experimental/utils/numbers.js
@@ -63,3 +63,51 @@ export const mapRange = (value, from1, to1, from2, to2) =>
  *  */
 export const angleBetween = (point1, point2) =>
   Math.atan2(point2.y - point1.y, point2.x - point1.x)
+
+/**
+* Constrains a value within a range
+*
+* @param {number} value
+* @param {number} min
+* @param {number} max
+* @return {number}
+*
+*  */
+export const clamp = (value, min, max) => {
+  return Math.max(min, Math.min(value, max))
+}
+
+/**
+ * ! TODO ! confusing naming
+* Scales a coordinate value
+*/
+export const scaleCoordinate = (coordinate, scaleRatio) => {
+  return scaleRatio * coordinate - coordinate
+}
+
+/**
+ * ! TODO ! confusing naming
+* Scales a @Point
+*/
+export const scalePoint = ({ x, y }, scale) => {
+  return {
+    x: scaleCoordinate(x, scale),
+    y: scaleCoordinate(y, scale)
+  }
+}
+
+/**
+ * ! TODO ! dynamic contraints based on width/height of board
+ */
+export const constrainTranslation = (targetTranslation, translationBounds) => {
+  const { x, y } = targetTranslation
+  let { xMax, xMin, yMax, yMin } = translationBounds
+  xMin = xMin != undefined ? xMin : -Infinity
+  yMin = yMin != undefined ? yMin : -Infinity
+  xMax = xMax != undefined ? xMax : Infinity
+  yMax = yMax != undefined ? yMax : Infinity
+  return {
+    x: clamp(x, xMin, xMax),
+    y: clamp(y, yMin, yMax)
+  }
+}
\ No newline at end of file
diff --git a/app/src/experimental/utils/svg.js b/app/src/experimental/utils/svg.js
index dcf070c..b93e8cd 100644
--- a/app/src/experimental/utils/svg.js
+++ b/app/src/experimental/utils/svg.js
@@ -1,6 +1,6 @@
 import * as win from '@/experimental/constants/window'
 import { generateLinkHandles } from '@/experimental/utils/nodes'
-import { mapRange, distance } from '@/experimental/utils/numbers'
+import { mapRange, distanceBetween } from '@/experimental/utils/numbers'
 
 export const generateBezierCurve = (from, to, tension) => {
   if (from && to) {
@@ -9,7 +9,7 @@ export const generateBezierCurve = (from, to, tension) => {
     // This is a simple way to adjust the link tension depending
     // on the distance the link covers
     const adjustedTension = mapRange(
-      distance(fromHandle, toHandle),
+      distanceBetween(fromHandle, toHandle),
       0,
       win.width,
       tension * 0.01,
@@ -27,7 +27,7 @@ export const makeBezier = (fromHandle, toHandle, tension) => {
   // This is a simple way to adjust the link tension depending
   // on the distance the link covers
   const adjustedTension = mapRange(
-    distance(fromHandle, toHandle),
+    distanceBetween(fromHandle, toHandle),
     0,
     win.width,
     tension * 0.01,
diff --git a/app/src/experimental/utils/view.js b/app/src/experimental/utils/view.js
new file mode 100644
index 0000000..96d4628
--- /dev/null
+++ b/app/src/experimental/utils/view.js
@@ -0,0 +1,134 @@
+import { clamp, lerpPoint, scalePoint, distanceBetween } from '@/experimental/utils/numbers'
+
+//////////////////////////////////////////////////////////////////////
+// DOM UTILITIES
+//////////////////////////////////////////////////////////////////////
+
+/**
+ * Returns position of interaction event both for the containing element
+ * and the adjusted board position beneath
+ *
+ * @param {HTMLElement} target - base element for event
+ * @param {Event} event
+ * @param {Object} translation translated board position in {x,y}
+ * @param {Number} scale board scale
+ * @returns {Object}  
+ *
+ *  */
+export const getNormalisedInteraction = (target, event, translation, scale) => {
+    const rect = target.getBoundingClientRect()
+    const x = event.clientX - parseInt(rect.left, 10)
+    const y = event.clientY - parseInt(rect.top, 10)
+
+    return {
+        relativePoint: { x, y },
+        boardPoint: containerToBoardPoint({ x, y }, translation, scale)
+    }
+}
+
+export const containerToBoardPoint = (containerPoint, translation, scale) => {
+    return {
+        x: parseInt((containerPoint.x + -translation.x) / scale, 10),
+        y: parseInt((containerPoint.y + -translation.y) / scale, 10)
+    }
+}
+
+// ! TODO !
+// export const boardToContainerPoint = (boardPoint, translation, scale) => {
+// }
+
+
+/**
+ * @param {HTMLElement} target - base element for event
+ * @param {Point} point point to transform
+ * @param {Object} translation translated board position in {x,y}
+ * @returns {Point}  
+ *
+ *  */
+export const getTranslatedPositionFromContainer = (target, point, translation) => {
+    const rect = target.getBoundingClientRect()
+
+    return {
+        x: point.x - (rect.left + translation.x),
+        y: point.y - (rect.top + translation.y)
+    }
+} 
+
+export const changeViewFromWheelEvent = (event, container, { translation, scale, minScale, maxScale }) => {
+    const scaleChange = 2 ** (event.deltaY * 0.002)
+    const newScale = clamp(
+        scale + (1 - scaleChange),
+        minScale,
+        maxScale
+    )
+    const mousePosition = getTranslatedPositionFromContainer(
+        container,
+        { x: event.clientX, y: event.clientY },
+        translation
+    )
+
+    const scaleRatio = newScale / (scale != 0 ? scale : 1)
+    const focalPtDelta = scalePoint(mousePosition, scaleRatio)
+    const newTranslation = {
+        x: translation.x - focalPtDelta.x,
+        y: translation.y - focalPtDelta.y
+    }
+
+    return [newScale, newTranslation]
+}
+
+export const changeViewFromMultiTouch = (startTouches, newTouches, container, {
+    minScale,
+    maxScale,
+    interaction
+}) => {
+    const dist0 = distanceBetween(startTouches[0], startTouches[1])
+    const dist1 = distanceBetween(newTouches[0], newTouches[1])
+    const scaleChange = dist1 / dist0
+    const startScale = interaction.origin.scale
+    const targetScale = startScale + (scaleChange - 1) * startScale
+    const newScale = clamp(targetScale, minScale, maxScale)
+
+    // calculate mid points
+    const newMidPoint = lerpPoint(
+        touchPoint(newTouches[0]),
+        touchPoint(newTouches[1]),
+        0.5
+    )
+    const startMidpoint = lerpPoint(
+        touchPoint(startTouches[0]),
+        touchPoint(startTouches[1]),
+        0.5
+    )
+    const dragDelta = {
+        x: newMidPoint.x - startMidpoint.x,
+        y: newMidPoint.y - startMidpoint.y
+    }
+    const scaleRatio = newScale / startScale
+    const focalPoint = getTranslatedPositionFromContainer(
+        container,
+        startMidpoint,
+        interaction.origin.pointer.translation
+    )
+    const focalPointDelta = scalePoint(focalPoint, scaleRatio)
+
+    const newTranslation = {
+        x: interaction.origin.pointer.translation.x - focalPointDelta.x + dragDelta.x,
+        y: interaction.origin.pointer.translation.y - focalPointDelta.y + dragDelta.y
+    }
+    return [newTranslation, newScale]
+}
+
+/**
+ * Normalises touch event position
+ *
+ * @param {Event} event
+ * @returns {Point}  
+ *
+ *  */
+export const touchPoint = touch => {
+    return {
+        x: touch.clientX,
+        y: touch.clientY
+    }
+}
\ No newline at end of file
diff --git a/app/src/views/Sandbox.vue b/app/src/views/Sandbox.vue
index b283c54..d656461 100644
--- a/app/src/views/Sandbox.vue
+++ b/app/src/views/Sandbox.vue
@@ -1,15 +1,19 @@
 <template>
-  <div class="wrapper" v-bind:style="modeContainerStyle">
+  <div ref="container" class="wrapper" v-bind:style="modeContainerStyle">
     <PanZoomContainer
       v-bind:width="width"
       v-bind:height="height"
       v-bind:scale="scale"
       v-bind:translation="translation"
-      v-bind:pan="activeMode.view.pan"
-      v-bind:zoom="activeMode.view.zoom"
     >
       <h1>Nodes</h1>
     </PanZoomContainer>
+    <SelectionLayer
+      v-if="domContainerReady"
+      v-bind:shape="interaction.shape"
+      v-bind:width="elementWidth"
+      v-bind:height="elementHeight"
+    />
     <ModeToolbar />
     <ViewToolbar />
   </div>
@@ -19,18 +23,25 @@
 import PanZoomContainer from '@/experimental/PanZoomContainer'
 import ModeToolbar from '@/experimental/ModeToolbar'
 import ViewToolbar from '@/experimental/ViewToolbar'
+import SelectionLayer from '@/experimental/layers/SelectionLayer'
 import { mapGetters, mapState } from 'vuex'
 
 export default {
   name: 'Sandbox',
   data: function() {
     return {
+      elementWidth: undefined,
+      elementHeight: undefined,
       width: 2000,
       height: 2000
     }
   },
   computed: {
+    domContainerReady() {
+      return !!this.elementWidth && !!this.elementHeight
+    },
     ...mapState({
+      interaction: state => state.ui.interaction,
       scale: state => state.ui.scale,
       translation: state => state.ui.translation
     }),
@@ -39,10 +50,25 @@ export default {
       modeContainerStyle: 'ui/modeContainerStyle'
     })
   },
+  mounted() {
+    window.addEventListener('resize', this.handleResize)
+    this.handleResize()
+  },
+  destroyed() {
+    window.removeEventListener('resize', this.handleResize)
+  },
+  methods: {
+    handleResize() {
+      const { offsetWidth, offsetHeight } = this.$refs.container
+      this.elementWidth = offsetWidth
+      this.elementHeight = offsetHeight
+    }
+  },
   components: {
     ModeToolbar,
     ViewToolbar,
-    PanZoomContainer
+    PanZoomContainer,
+    SelectionLayer
   }
 }
 </script>
-- 
GitLab