├── .babelrc ├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── blob_demo.gif ├── blob_prev.jpg ├── example1.gif ├── example2.gif ├── explanation.jpg ├── golf_demo.gif ├── golf_prev.jpg └── logo.png ├── examples ├── golf │ ├── client │ │ ├── assets │ │ │ └── style.css │ │ ├── index.html │ │ └── src │ │ │ └── app.js │ ├── package.json │ └── server │ │ └── index.js └── particles │ ├── package.json │ ├── server │ └── index.js │ └── static │ ├── index.html │ └── src │ └── app.js ├── package.json ├── src ├── client │ ├── converter.js │ ├── device.js │ ├── index.js │ ├── init.js │ ├── sensor.js │ └── style.css └── server │ ├── actions.js │ ├── debug-middleware.js │ ├── index.js │ ├── reducer.js │ └── utils.js ├── test ├── reducer.js └── utils.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parserOptions":{ 4 | "ecmaFeatures": { 5 | "experimentalObjectRestSpread": true 6 | } 7 | }, 8 | "rules": { 9 | "no-use-before-define": 0, 10 | "space-before-function-paren": ["error", "always"], 11 | "max-len": ["error", 120, 4] 12 | } 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.2.0] - 2016-10-18 8 | ### Added 9 | 10 | - Improve client library 11 | - add swipe animation 12 | - add connect button 13 | - handle resize 14 | - Improve golf demo 15 | - better graphics 16 | - accelerate ball based on orientation 17 | - Improve particle demo 18 | - fewer particles 19 | - make particles draggable 20 | 21 | ### Fixed 22 | - Fix collision detection of golf demo 23 | - Fix transform calculation when merging a cluster with multiple clients into another cluster 24 | 25 | ## [0.1.0] - 2016-09-30 26 | ### Added 27 | 28 | Initial release 29 | - support multiple clients 30 | - each device has initially it's own cluster 31 | - if two devices are connected with each other their clusters will be merged 32 | - custom server logic to handle events: init, merge or custom client action 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Tim Großmann, Paul Sonnentag 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **DISCLAIMER** 2 | > 3 | > This is a very early version of Swip.js. If the app doesn't respond you might have to reload the browser or restart the server. 4 | > It works best if you create a separate wireless network to connect multiple devices with your swip.js application 5 | 6 | 7 | 8 | # **Swip** 9 | 10 | [![GitHub license](https://img.shields.io/github/license/mashape/apistatus.svg)](https://github.com/timgrossmann/InstaPy/blob/master/LICENSE) 11 | [![built with JavaScript](https://img.shields.io/badge/built%20with-JavaScript-yellow.svg)](https://www.javascript.com) 12 | 13 | ### [Read the article about it here](https://medium.freecodecamp.com/what-if-all-your-mobile-devices-formed-a-single-screen-9c6ff01ed0c3) 14 | 15 | ## What if all your mobile devices were a single screen?… 16 | > This probably isn’t the most common question to ask yourself. But, just for a second, actually think about it, think about all the possibilities when being able to combine any kind of mobile devices, independent of the operating system… Welcome to swip.js 17 | 18 | 19 | 20 | ## Features 21 | - [x] Runs in the browser 22 | - [x] Completely platform independent 23 | - [x] Works on every device with a browser 24 | - [x] Open Source 25 | - [x] Free 26 | - [x] Open 27 | - [x] Community-based 28 | - [x] Collaborate! 29 | - [x] A library for you to use with your own project 30 | - [x] If you have an idea about what to build, do it! 31 | - [x] Two different examples 32 | - [x] Endless possibilities 33 | 34 | ## What we built with it 35 | ### Blobparticles 36 | 37 | 38 | ### Minigolf 39 | 40 | 41 | ## Try it out yourself! 42 | 43 | > You need node >= 6.x to run swip. To use swip you need to install its dependencies, the dependencies for the demos and build the client library. You can just run the commands below: 44 | 45 | ###### Setup 46 | ```bash 47 | npm install webpack -g 48 | npm install 49 | cd examples/golf 50 | npm install 51 | cd ../particles 52 | npm install 53 | cd ../.. 54 | npm run build 55 | ``` 56 | 57 | ###### Run the demos 58 | ```bash 59 | npm run golf 60 | npm run particles 61 | ``` 62 | 63 | ## Who we are 64 | [](http://github.com/paulsonnentag) 65 | **Paul Sonnentag** 66 | [](https://twitter.com/paulsonnentag) 67 | [](http://paulsonnentag.com) 68 | [](mailto:paul.sonnentag@gmail.com) 69 | > Passionate developer, studying computer science. At home on the web. Building things with JavaScript, Elm and Clojure. 70 | 71 | 72 | [](http://github.com/timgrossmann) 73 | **Tim Großmann** 74 | [](https://twitter.com/timigrossmann) 75 | [](https://medium.com/@TimGrossmann) 76 | [](mailto:contact.timgrossmann@gmail.com) 77 | [](https://www.facebook.com/profile.php?id=100000656212416) 78 | [](https://www.instagram.com/grossertim/) 79 | [](https://github.com/timgrossmann) 80 | 81 | > Passionate learner and developer. Studying computer science at the Media University. Looking forward to work with ingenious teams on challenging projects. Creator of [**InstaPY**](https://github.com/timgrossmann/InstaPy) 82 | -------------------------------------------------------------------------------- /assets/blob_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/blob_demo.gif -------------------------------------------------------------------------------- /assets/blob_prev.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/blob_prev.jpg -------------------------------------------------------------------------------- /assets/example1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/example1.gif -------------------------------------------------------------------------------- /assets/example2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/example2.gif -------------------------------------------------------------------------------- /assets/explanation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/explanation.jpg -------------------------------------------------------------------------------- /assets/golf_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/golf_demo.gif -------------------------------------------------------------------------------- /assets/golf_prev.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/golf_prev.jpg -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulsonnentag/swip/b9cad55558e15b783c8a5442c509027c1d3aba85/assets/logo.png -------------------------------------------------------------------------------- /examples/golf/client/assets/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /examples/golf/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Golf 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/golf/client/src/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function () { 3 | 'use strict'; 4 | 5 | var socket = io.connect(); 6 | 7 | swip.init({ socket: socket, container: document.getElementById('root') }, function (client) { 8 | var converter = client.converter; 9 | var stage = client.stage; 10 | var ctx = stage.getContext('2d'); 11 | 12 | var state = null; 13 | var dragPosition = null; 14 | var dragging = false; 15 | 16 | client.onClick(function (evt) { 17 | var hole = { x: evt.position.x, y: evt.position.y }; 18 | client.emit('setHole', hole); 19 | }); 20 | 21 | client.onDragStart(function (evt) { 22 | if (state) { 23 | var distanceX = evt.position[0].x - state.cluster.data.ball.x; 24 | var distanceY = evt.position[0].y - state.cluster.data.ball.y; 25 | var distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2)); 26 | 27 | if (distance < (2 * state.cluster.data.ball.radius)) { 28 | dragging = true; 29 | dragPosition = evt.position[0]; 30 | } 31 | } 32 | }); 33 | 34 | client.onDragMove(function (evt) { 35 | var distanceX = evt.position[0].x - state.cluster.data.ball.x; 36 | var distanceY = evt.position[0].y - state.cluster.data.ball.y; 37 | var distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2)); 38 | 39 | if (dragging) { 40 | if (distance > 150) { 41 | dragPosition = { 42 | x: state.cluster.data.ball.x + (distanceX / distance) * 150, 43 | y: state.cluster.data.ball.y + (distanceY / distance) * 150 44 | } 45 | } else { 46 | dragPosition = evt.position[0]; 47 | } 48 | } 49 | }); 50 | 51 | client.onDragEnd(function (evt) { 52 | if (dragging) { 53 | dragging = false; 54 | client.emit('hitBall', { 55 | speedX: (evt.position[0].x - state.cluster.data.ball.x) / 2, 56 | speedY: (evt.position[0].y - state.cluster.data.ball.y) / 2 57 | }); 58 | } 59 | }); 60 | 61 | swip.sensor.onChangeOrientation(throttle(function (evt) { 62 | client.emit('updateOrientation', { 63 | rotationX: evt.rotation.x, 64 | rotationY: evt.rotation.y 65 | }); 66 | }, 200)); 67 | 68 | 69 | client.onUpdate(function (evt) { 70 | state = evt; 71 | var client = state.client; 72 | var ball = state.cluster.data.ball; 73 | var hole = state.cluster.data.hole; 74 | 75 | ctx.save(); 76 | 77 | applyTransform(ctx, converter, client.transform); 78 | drawBackground(ctx, client); 79 | drawHole(ctx, hole); 80 | 81 | if (dragging) { 82 | drawArrow(ctx, ball, dragPosition); 83 | } 84 | 85 | drawBall(ctx, ball); 86 | drawWalls(ctx, client); 87 | 88 | ctx.restore(); 89 | }); 90 | }); 91 | 92 | function applyTransform (ctx, converter, transform) { 93 | ctx.translate(-converter.toDevicePixel(transform.x), -converter.toDevicePixel(transform.y)); 94 | ctx.scale(converter.toDevicePixel(1), converter.toDevicePixel(1)); 95 | 96 | } 97 | 98 | function drawBackground (ctx, client) { 99 | ctx.save(); 100 | ctx.fillStyle = '#80d735'; 101 | ctx.fillRect(client.transform.x, client.transform.y, client.size.width, client.size.height); 102 | ctx.restore(); 103 | } 104 | 105 | function drawWalls (ctx, client) { 106 | var openings = client.openings; 107 | var transformX = client.transform.x; 108 | var transformY = client.transform.y; 109 | var width = client.size.width; 110 | var height = client.size.height; 111 | 112 | ctx.save(); 113 | ctx.lineWidth = 40; 114 | ctx.shadowColor = '#dba863'; 115 | ctx.shadowBlur = 10; 116 | 117 | ctx.strokeStyle = '#ffde99'; 118 | 119 | // left 120 | ctx.beginPath(); 121 | ctx.moveTo(transformX, transformY); 122 | 123 | openings.left.sort(openingSort).forEach(function (opening) { 124 | ctx.lineTo(transformX, opening.start + transformY); 125 | ctx.stroke(); 126 | ctx.beginPath(); 127 | ctx.moveTo(transformX, opening.end + transformY); 128 | }); 129 | 130 | ctx.lineTo(transformX, height + transformY); 131 | ctx.stroke(); 132 | 133 | // right 134 | ctx.beginPath(); 135 | ctx.moveTo(width + transformX, transformY); 136 | 137 | openings.right.sort(openingSort).forEach(function (opening) { 138 | ctx.lineTo(width + transformX, opening.start + transformY); 139 | ctx.stroke(); 140 | ctx.beginPath(); 141 | ctx.moveTo(width + transformX, opening.end + transformY); 142 | }); 143 | 144 | ctx.lineTo(width + transformX, height + transformY); 145 | ctx.stroke(); 146 | 147 | // top 148 | ctx.beginPath(); 149 | ctx.moveTo(transformX, transformY); 150 | 151 | openings.top.sort(openingSort).forEach(function (opening) { 152 | ctx.lineTo(opening.start + transformX, transformY); 153 | ctx.stroke(); 154 | ctx.beginPath(); 155 | ctx.moveTo(opening.end + transformX, transformY); 156 | }); 157 | 158 | ctx.lineTo(width + transformX, transformY); 159 | ctx.stroke(); 160 | 161 | // bottom 162 | ctx.beginPath(); 163 | ctx.moveTo(transformX, height + transformY); 164 | 165 | openings.bottom.sort(openingSort).forEach(function (opening) { 166 | ctx.lineTo(opening.start + transformX, height + transformY); 167 | ctx.stroke(); 168 | ctx.beginPath(); 169 | ctx.moveTo(opening.end + transformX, height + transformY); 170 | }); 171 | 172 | ctx.lineTo(width + transformX, height + transformY); 173 | ctx.stroke(); 174 | ctx.restore(); 175 | } 176 | 177 | function openingSort (openingA, openingB) { 178 | return openingB.start - openingA.start; 179 | } 180 | 181 | function drawBall (ctx, ball) { 182 | ctx.save(); 183 | 184 | ctx.fillStyle = '#fff'; 185 | ctx.shadowBlur = 10; 186 | ctx.shadowColor = 'rgba(0, 0, 0, 0.2)'; 187 | 188 | ctx.beginPath(); 189 | ctx.arc(ball.x, ball.y, ball.radius, 0, 2 * Math.PI); 190 | ctx.fill(); 191 | 192 | ctx.restore(); 193 | } 194 | 195 | function drawArrow (ctx, ball, dragPosition) { 196 | var angle; 197 | 198 | ctx.save(); 199 | 200 | ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; 201 | ctx.lineWidth = 3; 202 | ctx.shadowBlur = 5; 203 | 204 | angle = -Math.atan2(dragPosition.x - ball.x, dragPosition.y - ball.y) + Math.PI / 2; 205 | 206 | ctx.beginPath(); 207 | ctx.arc(ball.x, ball.y, ball.radius * 2, angle + Math.PI / 2, angle - Math.PI / 2); 208 | ctx.arc(dragPosition.x, dragPosition.y, ball.radius, angle - Math.PI / 2, angle + Math.PI / 2); 209 | ctx.fill(); 210 | 211 | ctx.restore(); 212 | } 213 | 214 | function drawHole (ctx, hole) { 215 | ctx.save(); 216 | 217 | ctx.fillStyle = 'black'; 218 | ctx.strokeStyle = '#4b7f1f'; 219 | ctx.lineWidth = 2; 220 | 221 | ctx.beginPath(); 222 | ctx.arc(hole.x, hole.y, hole.radius, 0, 2 * Math.PI); 223 | ctx.fill(); 224 | ctx.stroke(); 225 | 226 | ctx.restore(); 227 | } 228 | 229 | function throttle(fn, threshhold, scope) { 230 | threshhold || (threshhold = 250); 231 | var last, 232 | deferTimer; 233 | return function () { 234 | var context = scope || this; 235 | 236 | var now = +new Date, 237 | args = arguments; 238 | if (last && now < last + threshhold) { 239 | // hold on to it 240 | clearTimeout(deferTimer); 241 | deferTimer = setTimeout(function () { 242 | last = now; 243 | fn.apply(context, args); 244 | }, threshhold); 245 | } else { 246 | last = now; 247 | fn.apply(context, args); 248 | } 249 | }; 250 | } 251 | 252 | }()); -------------------------------------------------------------------------------- /examples/golf/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swip-golf", 3 | "version": "1.0.0", 4 | "description": "golf example", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server/index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "express": "^4.14.0", 12 | "socket.io": "^1.4.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/golf/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | // eslint-disable-next-line new-cap 4 | const server = require('http').Server(app); 5 | const io = require('socket.io')(server); 6 | const swip = require('../../../src/server/index.js'); 7 | 8 | app.use(express.static(`${__dirname}/../client`)); 9 | 10 | const WALL_SIZE = 20; 11 | const SPEED_THRESHOLD = 50; 12 | const DOWNHILL_ACCELERATION_SCALE = 1 / 20; 13 | const ANGLE_INACCURACY = 3; 14 | 15 | swip(io, { 16 | cluster: { 17 | events: { 18 | update: (cluster) => { 19 | const ball = cluster.data.ball; 20 | const hole = cluster.data.hole; 21 | const clients = cluster.clients; 22 | 23 | let downhillAccelerationX = 0; 24 | let downhillAccelerationY = 0; 25 | let nextPosX = ball.x + ball.speedX; 26 | let nextPosY = ball.y + ball.speedY; 27 | let nextSpeedX = ball.speedX; 28 | let nextSpeedY = ball.speedY; 29 | 30 | const boundaryOffset = ball.radius + WALL_SIZE; 31 | const client = clients.find((c) => isParticleInClient(ball, c)); 32 | 33 | if (client) { 34 | if (Math.abs(client.data.rotationX) > ANGLE_INACCURACY) { 35 | downhillAccelerationX = (client.data.rotationX - ANGLE_INACCURACY) * DOWNHILL_ACCELERATION_SCALE; 36 | } 37 | 38 | if (Math.abs(client.data.rotationY) > ANGLE_INACCURACY) { 39 | downhillAccelerationY = (client.data.rotationY - ANGLE_INACCURACY) * DOWNHILL_ACCELERATION_SCALE; 40 | } 41 | 42 | // update speed and position if collision happens 43 | if (((ball.speedX < 0) && 44 | ((nextPosX - boundaryOffset) < client.transform.x) && 45 | !isWallOpenAtPosition(client.transform.y, client.openings.left, nextPosY))) { 46 | nextPosX = client.transform.x + boundaryOffset; 47 | nextSpeedX = ball.speedX * -1; 48 | } else if (((ball.speedX > 0) && 49 | ((nextPosX + boundaryOffset) > (client.transform.x + client.size.width)) && 50 | !isWallOpenAtPosition(client.transform.y, client.openings.right, nextPosY))) { 51 | nextPosX = client.transform.x + (client.size.width - boundaryOffset); 52 | nextSpeedX = ball.speedX * -1; 53 | } 54 | 55 | if (((ball.speedY < 0) && 56 | ((nextPosY - boundaryOffset) < client.transform.y && 57 | !isWallOpenAtPosition(client.transform.x, client.openings.top, nextPosX)))) { 58 | nextPosY = client.transform.y + boundaryOffset; 59 | nextSpeedY = ball.speedY * -1; 60 | } else if (((ball.speedY > 0) && 61 | ((nextPosY + boundaryOffset) > (client.transform.y + client.size.height)) && 62 | !isWallOpenAtPosition(client.transform.x, client.openings.bottom, nextPosX)) 63 | ) { 64 | nextPosY = client.transform.y + (client.size.height - boundaryOffset); 65 | nextSpeedY = ball.speedY * -1; 66 | } 67 | } else { // reset ball to first client of cluster 68 | const firstClient = clients[0]; 69 | nextPosX = firstClient.transform.x + (firstClient.size.width / 2); 70 | nextPosY = firstClient.transform.y + (firstClient.size.height / 2); 71 | nextSpeedX = 0; 72 | nextSpeedY = 0; 73 | } 74 | 75 | if (isInsideHole(hole, ball)) { 76 | nextPosX = (ball.x + hole.x) / 2; 77 | nextPosY = (ball.y + hole.y) / 2; 78 | nextSpeedX = 0; 79 | nextSpeedY = 0; 80 | } 81 | 82 | return { 83 | ball: { 84 | x: { $set: nextPosX }, 85 | y: { $set: nextPosY }, 86 | speedX: { $set: (nextSpeedX + downhillAccelerationX) * 0.97 }, 87 | speedY: { $set: (nextSpeedY + downhillAccelerationY) * 0.97 }, 88 | }, 89 | }; 90 | }, 91 | merge: () => ({}), 92 | }, 93 | init: () => ({ 94 | ball: { x: 50, y: 50, radius: 10, speedX: 0, speedY: 0 }, 95 | hole: { x: 200, y: 200, radius: 15 }, 96 | }), 97 | }, 98 | 99 | client: { 100 | init: () => ({ rotationX: 0, rotationY: 0 }), 101 | events: { 102 | 103 | hitBall: ({ cluster, client }, { speedX, speedY }) => ({ 104 | cluster: { 105 | data: { 106 | ball: { 107 | speedX: { $set: speedX }, 108 | speedY: { $set: speedY }, 109 | }, 110 | }, 111 | }, 112 | }), 113 | 114 | setHole: ({ cluster, client }, { x, y }) => ({ 115 | cluster: { 116 | data: { 117 | hole: { 118 | x: { $set: x }, 119 | y: { $set: y }, 120 | }, 121 | }, 122 | }, 123 | }), 124 | 125 | updateOrientation: ({ cluster, client }, { rotationX, rotationY }) => ({ 126 | client: { 127 | data: { 128 | rotationX: { $set: rotationX }, 129 | rotationY: { $set: rotationY }, 130 | }, 131 | }, 132 | }), 133 | }, 134 | }, 135 | }); 136 | 137 | function isParticleInClient (ball, client) { 138 | const leftSide = client.transform.x; 139 | const rightSide = (client.transform.x + client.size.width); 140 | const topSide = client.transform.y; 141 | const bottomSide = (client.transform.y + client.size.height); 142 | 143 | return ball.x < rightSide && ball.x > leftSide && ball.y > topSide && ball.y < bottomSide; 144 | } 145 | 146 | function isWallOpenAtPosition (transform, openings, particlePos) { 147 | return openings.some((opening) => ( 148 | particlePos >= (opening.start + transform) && particlePos <= (opening.end + transform) 149 | )); 150 | } 151 | 152 | function isInsideHole (hole, ball) { 153 | const distanceX = hole.x - ball.x; 154 | const distanceY = hole.y - ball.y; 155 | const distance = Math.sqrt(Math.pow(distanceX, 2) + Math.pow(distanceY, 2)); 156 | const speed = Math.sqrt(Math.pow(ball.speedX, 2) + Math.pow(ball.speedY, 2)); 157 | 158 | return distance <= hole.radius && speed < SPEED_THRESHOLD; 159 | } 160 | 161 | server.listen(3000); 162 | 163 | // eslint-disable-next-line no-console 164 | console.log('started server: http://localhost:3000'); 165 | -------------------------------------------------------------------------------- /examples/particles/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swip-particles", 3 | "version": "1.0.0", 4 | "description": "particle example", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node server/index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "express": "^4.14.0", 12 | "socket.io": "^1.4.8" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/particles/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const server = require('http').Server(app); 4 | const io = require('socket.io')(server); 5 | const swip = require('../../../src/server/index.js'); 6 | 7 | app.use(express.static(__dirname + './../static')); 8 | 9 | swip(io, { 10 | cluster: { 11 | events: { 12 | update: (cluster) => { 13 | const blobs = cluster.data.blobs; 14 | const clients = cluster.clients; 15 | 16 | const updatedBlobs = blobs.map((blob) => { 17 | const boundaryOffset = blob.size; 18 | const client = clients.find((c) => isParticleInClient(blob, c)); 19 | 20 | let nextPosX = blob.x + blob.speedX; 21 | let nextPosY = blob.y + blob.speedY; 22 | let nextSpeedX = blob.speedX; 23 | let nextSpeedY = blob.speedY; 24 | 25 | if (client) { // update speed and position if collision happens 26 | if (((blob.speedX < 0) && 27 | ((nextPosX - boundaryOffset) < client.transform.x) 28 | && !isWallOpenAtPosition(client.transform.y, client.openings.left, nextPosY))) { 29 | nextPosX = client.transform.x + boundaryOffset; 30 | nextSpeedX = blob.speedX * -1; 31 | } else if (((blob.speedX > 0) && 32 | ((nextPosX + boundaryOffset) > (client.transform.x + client.size.width)) 33 | && !isWallOpenAtPosition(client.transform.y, client.openings.right, nextPosY))) { 34 | nextPosX = client.transform.x + (client.size.width - boundaryOffset); 35 | nextSpeedX = blob.speedX * -1; 36 | } 37 | 38 | if (((blob.speedY < 0) && 39 | ((nextPosY - boundaryOffset) < client.transform.y 40 | && !isWallOpenAtPosition(client.transform.x, client.openings.top, nextPosX)))) { 41 | nextPosY = client.transform.y + boundaryOffset; 42 | nextSpeedY = blob.speedY * -1; 43 | } else if (((blob.speedY > 0) && 44 | ((nextPosY + boundaryOffset) > (client.transform.y + client.size.height)) 45 | && !isWallOpenAtPosition(client.transform.x, client.openings.bottom, nextPosX)) 46 | ) { 47 | nextPosY = client.transform.y + (client.size.height - boundaryOffset); 48 | nextSpeedY = blob.speedY * -1; 49 | } 50 | } else { // reset blob to first client of cluster 51 | const firstClient = clients[0]; 52 | nextPosX = firstClient.transform.x + (firstClient.size.width / 2); 53 | nextPosY = firstClient.transform.y + (firstClient.size.height / 2); 54 | nextSpeedX = 0; 55 | nextSpeedY = 0; 56 | } 57 | 58 | blob.x = nextPosX; 59 | blob.y = nextPosY; 60 | blob.speedX = nextSpeedX; 61 | blob.speedY = nextSpeedY; 62 | 63 | return blob; 64 | }); 65 | 66 | return { 67 | blobs: { $set: updatedBlobs }, 68 | }; 69 | }, 70 | merge: (cluster1, cluster2, transform) => ({ 71 | blobs: { $set: getNewParticleDist(cluster1, cluster2, transform) }, 72 | backgroundColor: { $set: cluster1.data.backgroundColor }, 73 | }), 74 | }, 75 | init: () => ({ blobs: [], backgroundColor: getRandomColor() }), 76 | }, 77 | 78 | client: { 79 | init: () => ({}), 80 | events: { 81 | addBlobs: ({ cluster, client }, { blobs }) => { 82 | return { 83 | cluster: { 84 | data: { blobs: { $push: blobs } }, 85 | }, 86 | }; 87 | }, 88 | updateBlobs: ({ cluster, client }, { blobs }) => { 89 | return { 90 | cluster: { 91 | data: { blobs: { $set: blobs } }, 92 | }, 93 | }; 94 | }, 95 | }, 96 | }, 97 | }); 98 | 99 | function isParticleInClient (particle, client) { 100 | const leftSide = client.transform.x; 101 | const rightSide = (client.transform.x + client.size.width); 102 | const topSide = client.transform.y; 103 | const bottomSide = (client.transform.y + client.size.height); 104 | 105 | if (particle.x < rightSide && particle.x > leftSide && particle.y > topSide && particle.y < bottomSide) { 106 | return true; 107 | } 108 | 109 | return false; 110 | } 111 | 112 | function isWallOpenAtPosition (transform, openings, particlePos) { 113 | return openings.some((opening) => ( 114 | particlePos >= (opening.start + transform) && particlePos <= (opening.end + transform) 115 | )); 116 | } 117 | 118 | function getNewParticleDist (cluster1, cluster2, transform) { 119 | cluster2.clients.forEach((client) => { 120 | for (let i = 0; i < cluster2.data.blobs.length; i++) { 121 | if (isParticleInClient(cluster2.data.blobs[i], client)) { 122 | cluster2.data.blobs[i].x += transform.x; 123 | cluster2.data.blobs[i].y += transform.y; 124 | } 125 | } 126 | }); 127 | 128 | return cluster1.data.blobs.concat(cluster2.data.blobs); 129 | } 130 | 131 | function getRandomColor () { 132 | const colors = ['#f16745', '#ffc65d', '#7bc8a4', '#4cc3d9', '#93648d']; 133 | return colors[Math.floor(Math.random() * colors.length)]; 134 | } 135 | 136 | server.listen(3000); 137 | 138 | // eslint-disable-next-line no-console 139 | console.log('started server: http://localhost:3000'); 140 | -------------------------------------------------------------------------------- /examples/particles/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Swip 6 | 7 | 8 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/particles/static/src/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function () { 3 | 'use strict'; 4 | 5 | var socket = io.connect(); 6 | 7 | swip.init({ socket: socket, container: document.getElementById('root'), type: 'canvas' }, function (client) { 8 | var converter = client.converter; 9 | var stage = client.stage; 10 | var ctx = stage.getContext('2d'); 11 | 12 | var counter = 0; 13 | var blobs = []; 14 | var activeBlobs = []; 15 | var clickedBlobs = []; 16 | 17 | client.onDragStart(function (evt) { 18 | evt.position.forEach(function (pos) { 19 | for (var i = 0; i < blobs.length; i++) { 20 | if (touchInRadius(pos.x, pos.y, blobs[i].x, blobs[i].y, blobs[i].size * 2)) { 21 | clickedBlobs.push(blobs.splice(i, 1)[0]); 22 | } 23 | } 24 | }); 25 | if (clickedBlobs.length > 0) { 26 | client.emit('updateBlobs', { blobs: blobs }); 27 | } 28 | 29 | if (clickedBlobs == false) { 30 | evt.position.forEach(function (pos) { 31 | activeBlobs.push({ 32 | x: pos.x, 33 | y: pos.y, 34 | speedX: 0, 35 | speedY: 0, 36 | size: converter.toAbsPixel(15) 37 | }); 38 | }); 39 | } 40 | }); 41 | 42 | client.onDragMove(function (evt) { 43 | if (clickedBlobs.length > 0) { 44 | if (counter >= 3) { 45 | evt.position.forEach(function (pos) { 46 | for (var i = 0; i < clickedBlobs.length; i++) { 47 | if (touchInRadius(pos.x, pos.y, clickedBlobs[i].x, clickedBlobs[i].y, clickedBlobs[i].size * 10)) { 48 | clickedBlobs[i].x = pos.x; 49 | clickedBlobs[i].y = pos.y; 50 | } 51 | } 52 | }); 53 | counter = 0; 54 | } 55 | counter++; 56 | } else { 57 | evt.position.forEach(function (pos) { 58 | for (var i = 0; i < activeBlobs.length; i++) { 59 | if (touchInRadius(pos.x, pos.y, activeBlobs[i].x, activeBlobs[i].y, activeBlobs[i].size)) { 60 | activeBlobs.splice(i, 1); 61 | i--; 62 | } 63 | } 64 | }); 65 | } 66 | }); 67 | 68 | client.onDragEnd(function (evt) { 69 | if (clickedBlobs == false) { 70 | evt.position.forEach(function (pos) { 71 | var emitBlobs = []; 72 | for (var i = 0; i < activeBlobs.length; i++) { 73 | if (touchInRadius(pos.x, pos.y, activeBlobs[i].x, activeBlobs[i].y, activeBlobs[i].size)) { 74 | emitBlobs.push(activeBlobs[i]); 75 | activeBlobs.splice(i, 1); 76 | i--; 77 | } 78 | } 79 | if (emitBlobs) { 80 | client.emit('addBlobs', { blobs: emitBlobs }); 81 | } 82 | }); 83 | } else { 84 | evt.position.forEach(function (pos) { 85 | var emitBlobs = []; 86 | for (var i = 0; i < clickedBlobs.length; i++) { 87 | var startX = clickedBlobs[i].x; 88 | var startY = clickedBlobs[i].y; 89 | 90 | if (touchInRadius(pos.x, pos.y, clickedBlobs[i].x, clickedBlobs[i].y, clickedBlobs[i].size * 40)) { 91 | clickedBlobs[i].x = pos.x; 92 | clickedBlobs[i].y = pos.y; 93 | clickedBlobs[i].speedX = (pos.x - startX) / 2; 94 | clickedBlobs[i].speedY = (pos.y - startY) / 2; 95 | emitBlobs.push(clickedBlobs.splice(i, 1)[0]); 96 | i--; 97 | } 98 | } 99 | client.emit('addBlobs', { blobs: emitBlobs }); 100 | }); 101 | } 102 | }); 103 | 104 | client.onUpdate(function (evt) { 105 | var updatedBlobs = evt.cluster.data.blobs; 106 | blobs = updatedBlobs; 107 | 108 | ctx.save(); 109 | 110 | applyTransform(ctx, converter, evt.client.transform); 111 | 112 | drawBackground(ctx, evt); 113 | drawOpenings(ctx, evt.client); 114 | increaseActiveBlobSize(activeBlobs, converter); 115 | drawBlobs(ctx, activeBlobs, clickedBlobs, updatedBlobs); 116 | 117 | ctx.restore(); 118 | }); 119 | }); 120 | 121 | function drawBackground (ctx, evt) { 122 | ctx.save(); 123 | 124 | ctx.fillStyle = evt.cluster.data.backgroundColor; 125 | ctx.fillRect(evt.client.transform.x, evt.client.transform.y, evt.client.size.width, evt.client.size.height); 126 | 127 | ctx.restore(); 128 | } 129 | 130 | function applyTransform (ctx, converter, transform) { 131 | ctx.translate(-converter.toDevicePixel(transform.x), -converter.toDevicePixel(transform.y)); 132 | ctx.scale(converter.toDevicePixel(1), converter.toDevicePixel(1)); 133 | } 134 | 135 | function increaseActiveBlobSize (activeBlobs, converter) { 136 | if (activeBlobs) { 137 | for(var i = 0; i < activeBlobs.length; i++) { 138 | if (activeBlobs[i].size < converter.toAbsPixel(100)) { 139 | activeBlobs[i].size += 1; 140 | } 141 | } 142 | } 143 | } 144 | 145 | function drawBlobs (ctx, activeBlobs, clickedBlobs, updatedBlobs) { 146 | ctx.shadowBlur = 0; 147 | 148 | ctx.save(); 149 | 150 | activeBlobs.forEach(function(blob) { 151 | ctx.beginPath(); 152 | ctx.arc(blob.x, blob.y, blob.size , 0, 2 * Math.PI, false); 153 | ctx.fillStyle = '#FFFFFF'; 154 | ctx.fill(); 155 | }); 156 | 157 | clickedBlobs.forEach(function(blob) { 158 | ctx.beginPath(); 159 | ctx.arc(blob.x, blob.y, blob.size , 0, 2 * Math.PI, false); 160 | ctx.fillStyle = '#FFFFFF'; 161 | ctx.fill(); 162 | }); 163 | 164 | updatedBlobs.forEach(function (blob) { 165 | ctx.beginPath(); 166 | ctx.arc(blob.x, blob.y, blob.size , 0, 2 * Math.PI, false); 167 | ctx.fillStyle = '#FFFFFF'; 168 | ctx.fill(); 169 | }); 170 | 171 | ctx.restore(); 172 | } 173 | 174 | function touchInRadius (posX, posY, blobX, blobY, blobsSize) { 175 | var inRadius = false; 176 | 177 | if ((posX < (blobX + blobsSize) && posX > (blobX - blobsSize)) && 178 | (posY < (blobY + blobsSize) && posY > (blobY - blobsSize))) { 179 | inRadius = true; 180 | } 181 | 182 | return inRadius; 183 | } 184 | 185 | function indexInClicked (index, clickedBlobs) { 186 | for (var i = 0; i < clickedBlobs.length; i++) { 187 | if (clickedBlobs[i].index == index) { 188 | return true; 189 | } 190 | } 191 | return false; 192 | } 193 | 194 | function drawOpenings (ctx, client) { 195 | var openings = client.openings; 196 | var transformX = client.transform.x; 197 | var transformY = client.transform.y; 198 | var width = client.size.width; 199 | var height = client.size.height; 200 | 201 | ctx.lineWidth = 5; 202 | ctx.shadowBlur = 5; 203 | 204 | openings.left.forEach(function (wall) { 205 | ctx.strokeStyle = "#ff9e00"; 206 | ctx.shadowColor = "#ff9e00"; 207 | 208 | ctx.beginPath(); 209 | ctx.moveTo(transformX, wall.start + transformY); 210 | ctx.lineTo(transformX, wall.end + transformY); 211 | ctx.stroke(); 212 | }); 213 | 214 | openings.top.forEach(function (wall) { 215 | ctx.strokeStyle = "#0084FF"; 216 | ctx.shadowColor = "#0084FF"; 217 | 218 | ctx.beginPath(); 219 | ctx.moveTo(wall.start + transformX, transformY); 220 | ctx.lineTo(wall.end + transformX, transformY); 221 | ctx.stroke(); 222 | }); 223 | 224 | openings.right.forEach(function (wall) { 225 | ctx.strokeStyle = "#0084FF"; 226 | ctx.shadowColor = "#0084FF"; 227 | 228 | ctx.beginPath(); 229 | ctx.moveTo(width + transformX, wall.start + transformY); 230 | ctx.lineTo(width + transformX, wall.end + transformY); 231 | ctx.stroke(); 232 | }); 233 | 234 | openings.bottom.forEach(function (wall) { 235 | ctx.strokeStyle = "#ff9e00"; 236 | ctx.shadowColor = "#ff9e00"; 237 | 238 | ctx.beginPath(); 239 | ctx.moveTo(wall.start + transformX, height + transformY); 240 | ctx.lineTo(wall.end + transformX, height + transformY); 241 | ctx.stroke(); 242 | }); 243 | } 244 | }()); 245 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swip", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "src/server/index.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "dev": "webpack --watch", 9 | "build": "webpack", 10 | "particles": "node examples/particles/server/index.js", 11 | "particles:dev": "nodemon examples/particles/server/index.js", 12 | "golf": "node examples/golf/server/index.js", 13 | "golf:dev": "nodemon examples/golf/server/index.js", 14 | "contact": "node examples/contact-share/server/index.js", 15 | "contact:dev": "nodemon examples/contact-share/server/index.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/paulsonnentag/swip.git" 20 | }, 21 | "author": "Tim Großmann, Paul Sonnentag", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/paulsonnentag/swip/issues" 25 | }, 26 | "homepage": "https://github.com/paulsonnentag/swip#readme", 27 | "dependencies": { 28 | "immutability-helper": "^2.0.0", 29 | "lodash": "^4.13.1", 30 | "redux": "^3.5.2", 31 | "redux-logger": "^2.6.1", 32 | "redux-node-logger": "0.0.3", 33 | "uid": "0.0.2" 34 | }, 35 | "peerDependencies": { 36 | "socket.io": "1.4.x" 37 | }, 38 | "devDependencies": { 39 | "babel-core": "^6.11.4", 40 | "babel-loader": "^6.2.4", 41 | "babel-preset-es2015": "^6.9.0", 42 | "css-loader": "^0.25.0", 43 | "eslint": "^3.1.1", 44 | "eslint-config-airbnb-base": "^4.0.2", 45 | "eslint-plugin-import": "^1.11.1", 46 | "mocha": "^3.0.2", 47 | "should": "^10.0.0", 48 | "should-sinon": "0.0.5", 49 | "sinon": "^1.17.5", 50 | "style-loader": "^0.13.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/client/converter.js: -------------------------------------------------------------------------------- 1 | /* global localStorage document screen window */ 2 | const SIZE_REFERENCE = 60; 3 | 4 | class Converter { 5 | constructor (screenSize) { 6 | this.screenSize = screenSize; 7 | this.scalingFactor = getScalingFactor(screenSize); 8 | } 9 | 10 | toDevicePixel (value) { 11 | return value / this.scalingFactor; 12 | } 13 | 14 | toAbsPixel (value) { 15 | return value * this.scalingFactor; 16 | } 17 | 18 | convertClickPos (transform, evt) { 19 | return { 20 | position: { 21 | x: this.toAbsPixel(evt.clientX) + transform.x, 22 | y: this.toAbsPixel(evt.clientY) + transform.y, 23 | }, 24 | originalEvent: evt, 25 | }; 26 | } 27 | 28 | convertTouchPos (transform, evt) { 29 | const event = { 30 | position: [], 31 | originalEvent: evt, 32 | }; 33 | 34 | for (let i = 0; i < evt.changedTouches.length; i++) { 35 | const currTouched = evt.changedTouches[i]; 36 | 37 | event.position.push({ 38 | x: this.toAbsPixel(currTouched.clientX) + transform.x, 39 | y: this.toAbsPixel(currTouched.clientY) + transform.y, 40 | }); 41 | } 42 | 43 | return event; 44 | } 45 | } 46 | 47 | function getScalingFactor (screenSize) { 48 | const diagonalPixel = Math.sqrt(Math.pow(screen.height, 2) + Math.pow(screen.width, 2)); 49 | const diagonalScreenCM = screenSize * 2.54; 50 | const pixelPerCentimeter = diagonalPixel / diagonalScreenCM; 51 | 52 | return SIZE_REFERENCE / pixelPerCentimeter; 53 | } 54 | 55 | export default Converter; 56 | -------------------------------------------------------------------------------- /src/client/device.js: -------------------------------------------------------------------------------- 1 | /* global localStorage prompt document */ 2 | 3 | const DEVICE_SIZE_KEY = 'SWIP_DEVICE_SIZE'; 4 | 5 | function requestSize () { 6 | const storedSize = parseFloat(localStorage.getItem(DEVICE_SIZE_KEY)); 7 | 8 | if (!Number.isNaN(storedSize)) { 9 | return storedSize; 10 | } 11 | 12 | /* eslint-disable no-alert */ 13 | const inputSize = parseFloat(prompt('Please enter the device size in "(inch): ')); 14 | /* eslint-enable no-alert */ 15 | 16 | if (!Number.isNaN(inputSize)) { 17 | localStorage.setItem(DEVICE_SIZE_KEY, inputSize); 18 | } 19 | 20 | return inputSize; 21 | } 22 | 23 | function requestFullscreen (element) { 24 | if (element.requestFullscreen) { 25 | element.requestFullscreen(); 26 | } else if (element.mozRequestFullScreen) { 27 | element.mozRequestFullScreen(); 28 | } else if (element.webkitRequestFullscreen) { 29 | element.webkitRequestFullscreen(); 30 | } else if (element.msRequestFullscreen) { 31 | element.msRequestFullscreen(); 32 | } 33 | } 34 | 35 | function hasFullscreenSupport () { 36 | const element = document.documentElement; 37 | 38 | return ( 39 | element.requestFullscreen || 40 | element.mozRequestFullScreen || 41 | element.webkitRequestFullscreen || 42 | element.msRequestFullscreen 43 | ); 44 | } 45 | 46 | export default { 47 | requestSize, 48 | requestFullscreen, 49 | hasFullscreenSupport, 50 | }; 51 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | /* global window */ 2 | import sensor from './sensor'; 3 | import device from './device'; 4 | import init from './init'; 5 | 6 | window.swip = { 7 | sensor, 8 | device, 9 | init, 10 | }; 11 | -------------------------------------------------------------------------------- /src/client/init.js: -------------------------------------------------------------------------------- 1 | /* global document window screen */ 2 | 3 | import device from './device'; 4 | import sensor from './sensor'; 5 | import Converter from './converter'; 6 | import './style.css'; 7 | 8 | function init ({ socket, container, type }, initApp) { 9 | return new ClientView({ socket, container, type, initApp }); 10 | } 11 | 12 | class ClientView { 13 | 14 | constructor ({ container, type, socket, initApp }) { 15 | this.socket = socket; 16 | 17 | this.initApp = initApp; 18 | 19 | // init container 20 | this.container = container; 21 | this.container.classList.add('SwipRoot'); 22 | this.container.innerHTML = ''; 23 | 24 | // init stage 25 | this.stage = getStage(type); 26 | this.stage.resize(container.clientWidth, container.clientHeight); 27 | this.container.appendChild(this.stage.element); 28 | window.addEventListener('resize', () => { 29 | this.stage.resize(container.clientWidth, container.clientHeight); 30 | }); 31 | 32 | 33 | // init swip points 34 | this.swipPoints = new SwipPoints(); 35 | this.container.appendChild(this.swipPoints.element); 36 | 37 | this.size = device.requestSize(); 38 | 39 | // add connect button if size is not set 40 | if (Number.isNaN(this.size)) { 41 | this.connectButton = document.createElement('button'); 42 | this.connectButton.innerText = 'connect'; 43 | this.connectButton.classList.add('SwipButton'); 44 | this.connectButton.onclick = () => this.connect(); 45 | this.container.appendChild(this.connectButton); 46 | } else { 47 | this.initClient(); 48 | } 49 | } 50 | 51 | initClient () { 52 | this.client = new Client({ 53 | size: this.size, 54 | socket: this.socket, 55 | stage: this.stage.element, 56 | swipPoints: this.swipPoints, 57 | container: this.container, 58 | }); 59 | 60 | window.addEventListener('resize', () => this.client.reconnect()); 61 | 62 | this.initApp(this.client); 63 | } 64 | 65 | connect () { 66 | this.size = device.requestSize(); 67 | 68 | if (!Number.isNaN(this.size)) { 69 | this.connectButton.style.display = 'none'; 70 | 71 | this.initClient(); 72 | } 73 | } 74 | } 75 | 76 | class Client { 77 | 78 | constructor ({ size, stage, container, socket, swipPoints }) { 79 | this.converter = new Converter(size); 80 | this.stage = stage; 81 | this.container = container; 82 | this.swipPoints = swipPoints; 83 | this.socket = socket; 84 | this.state = { 85 | client: { 86 | transform: { x: 0, y: 0 }, 87 | }, 88 | }; 89 | 90 | this.connect(); 91 | this.initEventListener(); 92 | } 93 | 94 | connect () { 95 | this.socket.emit('CONNECT', { 96 | size: { 97 | width: this.converter.toAbsPixel(this.stage.clientWidth), 98 | height: this.converter.toAbsPixel(this.stage.clientHeight), 99 | }, 100 | }); 101 | } 102 | 103 | reconnect () { 104 | this.socket.emit('RECONNECT', { 105 | size: { 106 | width: this.converter.toAbsPixel(this.stage.clientWidth), 107 | height: this.converter.toAbsPixel(this.stage.clientHeight), 108 | }, 109 | }); 110 | } 111 | 112 | initEventListener () { 113 | sensor.onSwipe(this.container, (evt) => { 114 | const position = { 115 | x: this.converter.toAbsPixel(evt.position.x), 116 | y: this.converter.toAbsPixel(evt.position.y), 117 | }; 118 | 119 | this.swipPoints.animatePoint(evt.position.x, evt.position.y); 120 | 121 | this.socket.emit('SWIPE', { 122 | direction: evt.direction, 123 | position, 124 | }); 125 | }); 126 | } 127 | 128 | onClick (callback) { 129 | this.stage.addEventListener('click', (evt) => { 130 | callback(this.converter.convertClickPos(this.state.client.transform, evt)); 131 | }); 132 | } 133 | 134 | onDragStart (callback) { 135 | this.stage.addEventListener('touchstart', (evt) => { 136 | callback(this.converter.convertTouchPos(this.state.client.transform, evt)); 137 | }); 138 | } 139 | 140 | onDragMove (callback) { 141 | this.stage.addEventListener('touchmove', (evt) => { 142 | evt.preventDefault(); 143 | callback(this.converter.convertTouchPos(this.state.client.transform, evt)); 144 | }); 145 | } 146 | 147 | onDragEnd (callback) { 148 | this.stage.addEventListener('touchend', (evt) => { 149 | callback(this.converter.convertTouchPos(this.state.client.transform, evt)); 150 | }); 151 | } 152 | 153 | onUpdate (callback) { 154 | this.socket.on('CHANGED', (state) => { 155 | this.state = state; 156 | callback(state); 157 | }); 158 | } 159 | 160 | emit (type, data) { 161 | this.socket.emit('CLIENT_ACTION', { type, data }); 162 | } 163 | } 164 | 165 | 166 | class SwipPoints { 167 | constructor () { 168 | this.initPoints(); 169 | } 170 | 171 | initPoints () { 172 | let i; 173 | this.nextPoint = 0; 174 | this.points = []; 175 | 176 | this.element = document.createElement('div'); 177 | 178 | for (i = 0; i < 5; i++) { 179 | const point = this.points[i] = document.createElement('div'); 180 | point.classList.add('SwipPoint'); 181 | this.element.appendChild(point); 182 | } 183 | } 184 | 185 | animatePoint (x, y) { 186 | const point = this.points[this.nextPoint]; 187 | 188 | point.style.top = `${y}px`; 189 | point.style.left = `${x}px`; 190 | point.classList.remove('SwipPoint--start-animation'); 191 | 192 | // force reflow 193 | void point.offsetWidth; 194 | 195 | point.classList.add('SwipPoint--start-animation'); 196 | 197 | this.nextPoint = (this.nextPoint + 1) % this.points.length; 198 | } 199 | } 200 | 201 | function getStage (type) { 202 | if (type === 'dom') { 203 | return new DOMStage(); 204 | } 205 | 206 | return new CanvasStage(); 207 | } 208 | 209 | class CanvasStage { 210 | constructor () { 211 | this.element = document.createElement('canvas'); 212 | this.element.style.cursor = 'pointer'; 213 | } 214 | 215 | resize (width, height) { 216 | this.element.width = width; 217 | this.element.height = height; 218 | } 219 | } 220 | 221 | class DOMStage { 222 | constructor () { 223 | this.element = document.createElement('div'); 224 | } 225 | 226 | resize (width, height) { 227 | this.element.style.width = `${width}px`; 228 | this.element.style.height = `${height}px`; 229 | } 230 | } 231 | 232 | export default init; 233 | -------------------------------------------------------------------------------- /src/client/sensor.js: -------------------------------------------------------------------------------- 1 | /* global window screen */ 2 | 3 | const MIN_SWIPE_DIST = 5; 4 | const MOTION_TOLERANCE = 15; 5 | const startPoints = {}; 6 | 7 | function onSwipe (element, callback) { 8 | element.addEventListener('touchmove', touchMoveHandler); 9 | 10 | element.addEventListener('touchstart', touchStartHandler); 11 | 12 | element.addEventListener('touchend', (evt) => touchEndHandler(evt, callback)); 13 | } 14 | 15 | function touchStartHandler (evt) { 16 | Array.prototype.slice.apply(evt.changedTouches).forEach((touch) => { 17 | startPoints[touch.identifier] = { 18 | x: touch.clientX, 19 | y: touch.clientY, 20 | }; 21 | }); 22 | } 23 | 24 | function touchMoveHandler (evt) { 25 | evt.preventDefault(); 26 | } 27 | 28 | function touchEndHandler (evt, callback) { 29 | Array.prototype.slice.apply(evt.changedTouches).forEach((touch) => { 30 | const start = startPoints[touch.identifier]; 31 | const end = { 32 | x: touch.clientX, 33 | y: touch.clientY, 34 | }; 35 | 36 | const diffX = Math.abs(end.x - start.x); 37 | const diffY = Math.abs(end.y - start.y); 38 | 39 | const vertBorder = window.innerHeight / 10; 40 | const horBorder = window.innerWidth / 10; 41 | 42 | if (diffX > diffY && diffX > MIN_SWIPE_DIST) { 43 | if (end.x < start.x && end.x <= horBorder) { 44 | callback({ direction: 'LEFT', position: { x: 0, y: end.y } }); 45 | } else if (end.x > start.x && end.x >= window.innerWidth - horBorder) { 46 | callback({ direction: 'RIGHT', position: { x: window.innerWidth, y: end.y } }); 47 | } 48 | } else if (diffY > diffX && diffY > MIN_SWIPE_DIST) { 49 | if (end.y < start.y && end.y <= vertBorder) { 50 | callback({ direction: 'UP', position: { x: end.x, y: 0 } }); 51 | } else if (end.y > start.y && end.y >= window.innerHeight - vertBorder) { 52 | callback({ direction: 'DOWN', position: { x: end.x, y: window.innerHeight } }); 53 | } 54 | } 55 | }); 56 | } 57 | 58 | function onMove (callback) { 59 | window.addEventListener('devicemotion', (evt) => { 60 | const x = evt.acceleration.x; 61 | const y = evt.acceleration.y; 62 | const z = evt.acceleration.z; 63 | 64 | const max = Math.max(z, x, y); 65 | 66 | if (max > MOTION_TOLERANCE) { 67 | callback(); 68 | } 69 | }); 70 | } 71 | 72 | function onChangeOrientation (callback) { 73 | let prevBeta = null; 74 | let prevGamma = null; 75 | 76 | window.addEventListener('deviceorientation', (evt) => { 77 | const beta = Math.round(evt.beta); 78 | const gamma = Math.round(evt.gamma); 79 | 80 | if (beta !== prevBeta || gamma !== prevGamma) { 81 | const orientation = screen.orientation || screen.mozOrientation || screen.msOrientation; 82 | const rotation = getRotation({ orientation, beta, gamma }); 83 | 84 | callback({ rotation }); 85 | } 86 | 87 | prevBeta = beta; 88 | prevGamma = gamma; 89 | }); 90 | } 91 | 92 | function getRotation ({ orientation, beta, gamma }) { 93 | switch (orientation.type) { 94 | case 'portrait-primary': 95 | return { x: gamma, y: beta }; 96 | 97 | case 'portrait-secondary': 98 | return { x: gamma, y: beta }; 99 | 100 | 101 | case 'landscape-primary': 102 | return { x: beta, y: -gamma }; 103 | 104 | 105 | case 'landscape-secondary': 106 | return { x: -beta, y: gamma }; 107 | 108 | default: 109 | return { x: 0, y: 0 }; 110 | } 111 | } 112 | 113 | export default { 114 | onSwipe, 115 | onMove, 116 | onChangeOrientation, 117 | }; 118 | -------------------------------------------------------------------------------- /src/client/style.css: -------------------------------------------------------------------------------- 1 | .SwipRoot { 2 | position: relative; 3 | height: 100%; 4 | width: 100%; 5 | -webkit-user-select: none; 6 | -moz-user-select: none; 7 | -ms-user-select: none; 8 | user-select: none; 9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 10 | } 11 | 12 | .SwipButton { 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | margin-left: -100px; 17 | margin-top: -25px; 18 | background: #2196F3; 19 | border: 0; 20 | border-radius: 3px; 21 | color: #fff; 22 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1); 23 | width: 200px; 24 | height: 50px; 25 | font-size: 1.3em; 26 | } 27 | 28 | .SwipStage { 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | bottom: 0; 34 | } 35 | 36 | 37 | .SwipPoint { 38 | position: absolute; 39 | pointer-events: none; 40 | top: 0; 41 | left: 0; 42 | margin: -35px 0 0 -35px; 43 | width: 70px; 44 | height: 70px; 45 | border-radius: 50%; 46 | background-color: rgba(125, 125, 125, 0.5); 47 | opacity: 0; 48 | } 49 | 50 | .SwipPoint--start-animation { 51 | animation: expand 0.3s; 52 | } 53 | 54 | @keyframes expand { 55 | 0% { 56 | transform: scale(0); 57 | opacity: 0; 58 | } 59 | 60 | 25% { 61 | opacity: 1; 62 | } 63 | 64 | 75% { 65 | opacity: 1; 66 | } 67 | 68 | 100% { 69 | opacity: 0; 70 | transform: scale(1); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/server/actions.js: -------------------------------------------------------------------------------- 1 | const TYPE = { 2 | CLIENT_ACTION: 'CLIENT_ACTION', 3 | CONNECT: 'CONNECT', 4 | DISCONNECT: 'DISCONNECT', 5 | RECONNECT: 'RECONNECT', 6 | LEAVE_CLUSTER: 'LEAVE_CLUSTER', 7 | SWIPE: 'SWIPE', 8 | NEXT_STATE: 'NEXT_STATE', 9 | CHANGED: 'CHANGED', 10 | }; 11 | 12 | function connect (id, { size }) { 13 | return { 14 | type: TYPE.CONNECT, 15 | data: { id, size }, 16 | }; 17 | } 18 | 19 | function swipe (id, { position, direction }) { 20 | return { 21 | type: TYPE.SWIPE, 22 | data: { id, position, direction }, 23 | }; 24 | } 25 | 26 | function leaveCluster (id) { 27 | return { 28 | type: TYPE.LEAVE_CLUSTER, 29 | data: { id }, 30 | }; 31 | } 32 | 33 | function disconnect (id) { 34 | return { 35 | type: TYPE.DISCONNECT, 36 | data: { id }, 37 | }; 38 | } 39 | 40 | function reconnect (id, { size }) { 41 | return { 42 | type: TYPE.RECONNECT, 43 | data: { id, size }, 44 | }; 45 | } 46 | 47 | function clientAction (id, { type, data }) { 48 | return { 49 | type: TYPE.CLIENT_ACTION, 50 | data: { id, type, data }, 51 | }; 52 | } 53 | 54 | 55 | function nextState () { 56 | return { 57 | type: TYPE.NEXT_STATE, 58 | data: {}, 59 | }; 60 | } 61 | 62 | module.exports = { 63 | TYPE, 64 | connect, 65 | swipe, 66 | leaveCluster, 67 | disconnect, 68 | reconnect, 69 | clientAction, 70 | nextState, 71 | }; 72 | -------------------------------------------------------------------------------- /src/server/debug-middleware.js: -------------------------------------------------------------------------------- 1 | const MAX_LOG_SIZE = 25; 2 | 3 | let log = []; 4 | 5 | function debugMiddleware ({ getState }) { 6 | return (next) => 7 | (action) => { 8 | let result; 9 | const prevState = getState(); 10 | 11 | try { 12 | result = next(action); 13 | } catch (e) { 14 | console.log('============================='); 15 | console.log(JSON.stringify(addToLog(log, { nextState: getState(), prevState, action }))); 16 | console.log('============================='); 17 | console.log(e.message); 18 | console.log(e.stack); 19 | console.log('============================='); 20 | 21 | process.exit(); 22 | } 23 | 24 | const nextState = getState(); 25 | 26 | log = addToLog(log, { action, prevState, nextState }); 27 | 28 | return result; 29 | }; 30 | } 31 | 32 | function addToLog (l, entry) { 33 | return [entry].concat(l).slice(0, MAX_LOG_SIZE); 34 | } 35 | 36 | module.exports = debugMiddleware; 37 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const uid = require('uid'); 3 | const read = require('fs').readFileSync; 4 | const { createStore, applyMiddleware } = require('redux'); 5 | const clientSource = read(require.resolve('../../dist/bundle.js'), 'utf-8'); 6 | const actions = require('./actions'); 7 | const reducer = require('./reducer'); 8 | const utils = require('./utils'); 9 | const debugMiddleware = require('./debug-middleware.js'); 10 | 11 | function swip (io, config) { 12 | const store = createStore(reducer(config), applyMiddleware(debugMiddleware)); 13 | 14 | io.on('connection', (socket) => { 15 | const id = uid(); 16 | 17 | socket.on('disconnect', () => { 18 | unsubscribe(); 19 | store.dispatch(actions.disconnect(id)); 20 | }); 21 | 22 | socket.on(actions.TYPE.CONNECT, (data) => store.dispatch(actions.connect(id, data))); 23 | socket.on(actions.TYPE.SWIPE, (data) => store.dispatch(actions.swipe(id, data))); 24 | socket.on(actions.TYPE.LEAVE_CLUSTER, () => store.dispatch(actions.leaveCluster(id))); 25 | socket.on(actions.TYPE.DISCONNECT, () => { 26 | unsubscribe(); 27 | store.dispatch(actions.disconnect(id)); 28 | }); 29 | socket.on(actions.TYPE.RECONNECT, (data) => store.dispatch(actions.reconnect(id, data))); 30 | socket.on(actions.TYPE.CLIENT_ACTION, (data) => store.dispatch(actions.clientAction(id, data))); 31 | 32 | const unsubscribe = store.subscribe(() => { 33 | const state = store.getState(); 34 | const client = state.clients[id]; 35 | 36 | if (_.isNil(client)) { 37 | return; 38 | } 39 | 40 | const clientState = utils.getClientState(state, id); 41 | 42 | socket.emit(actions.TYPE.CHANGED, clientState); 43 | }); 44 | }); 45 | 46 | setInterval(() => store.dispatch(actions.nextState()), 33); 47 | 48 | attachServe(io.httpServer); 49 | } 50 | 51 | /* http serve adapted from socket.io */ 52 | 53 | function attachServe (srv) { 54 | const url = '/swip/swip.js'; 55 | const evs = srv.listeners('request').slice(0); 56 | 57 | srv.removeAllListeners('request'); 58 | srv.on('request', (req, res) => { 59 | if (req.url.indexOf(url) === 0) { 60 | serve(req, res); 61 | } else { 62 | for (let i = 0; i < evs.length; i++) { 63 | evs[i].call(srv, req, res); 64 | } 65 | } 66 | }); 67 | } 68 | 69 | function serve (req, res) { 70 | res.setHeader('Content-Type', 'application/javascript'); 71 | res.writeHead(200); 72 | res.end(clientSource); 73 | } 74 | 75 | module.exports = swip; 76 | -------------------------------------------------------------------------------- /src/server/reducer.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const uid = require('uid'); 3 | const update = require('immutability-helper'); 4 | const actions = require('./actions'); 5 | const utils = require('./utils'); 6 | 7 | const SWIPE_DELAY_TOLERANCE = 100; 8 | 9 | const initialState = { 10 | clusters: {}, 11 | clients: {}, 12 | swipes: [], 13 | }; 14 | 15 | function createReducer (config) { 16 | return (state = initialState, { type, data }) => { 17 | switch (type) { 18 | case actions.TYPE.NEXT_STATE: 19 | return nextState(state); 20 | 21 | case actions.TYPE.CLIENT_ACTION: 22 | return clientAction(state, data); 23 | 24 | case actions.TYPE.CONNECT: 25 | return connect(state, data); 26 | 27 | case actions.TYPE.SWIPE: 28 | return doSwipe(state, data); 29 | 30 | case actions.TYPE.DISCONNECT: 31 | return disconnect(state, data); 32 | 33 | case actions.TYPE.RECONNECT: 34 | return reconnect(state, data); 35 | 36 | default: 37 | return state; 38 | } 39 | }; 40 | 41 | function nextState (state) { 42 | const updateClient = config.client.events.update; 43 | const updateCluster = config.cluster.events.update; 44 | const changes = {}; 45 | 46 | if (_.isFunction(updateCluster)) { 47 | changes.clusters = getAllChanges(state.clusters, _.partial(getClusterChanges, state, updateCluster)); 48 | } 49 | 50 | if (_.isFunction(updateClient)) { 51 | changes.clients = getAllChanges(state.clients, _.partial(getClientChanges, state, updateClient)); 52 | } 53 | 54 | return update(state, changes); 55 | } 56 | 57 | function getAllChanges (entities, update) { 58 | const changes = _.map(entities, update); 59 | const ids = _.keys(entities); 60 | return _.zipObject(ids, changes); 61 | } 62 | 63 | function getClusterChanges (state, next, cluster) { 64 | const clusterState = utils.getClusterState(state, cluster.id); 65 | return { data: next(clusterState) }; 66 | } 67 | 68 | function getClientChanges (state, next, client) { 69 | const clientState = utils.getClientState(state, client.id); 70 | return { data: next(clientState) }; 71 | } 72 | 73 | function clientAction (state, { id, type, data }) { 74 | const handler = config.client.events[type]; 75 | 76 | if (!_.isFunction(handler)) { 77 | throw new Error(`Unhandled event: ${type}`); 78 | } 79 | 80 | const client = state.clients[id]; 81 | 82 | if (!client) { 83 | return state; 84 | } 85 | 86 | const clientEventState = utils.getClientState(state, client.id); 87 | const stateUpdates = handler(clientEventState, data); 88 | const changes = {}; 89 | 90 | 91 | if (stateUpdates.cluster) { 92 | changes.clusters = { 93 | [client.clusterID]: stateUpdates.cluster, 94 | }; 95 | } 96 | 97 | if (stateUpdates.client) { 98 | changes.clients = { 99 | [client.id]: stateUpdates.client, 100 | }; 101 | } 102 | 103 | return update(state, changes); 104 | } 105 | 106 | function connect (state, { id, size }) { 107 | const clusterID = uid(); 108 | const openings = { 109 | top: [], 110 | bottom: [], 111 | right: [], 112 | left: [], 113 | }; 114 | const client = { id, size, transform: { x: 0, y: 0 }, adjacentClientIDs: [], clusterID, openings }; 115 | 116 | const clientData = config.client.init(client); 117 | const clusterData = config.cluster.init(client); 118 | 119 | const changes = { 120 | clusters: { 121 | [clusterID]: { $set: { id: clusterID, data: clusterData } }, 122 | }, 123 | clients: { 124 | [id]: { $set: _.assign({}, client, { data: clientData }) }, 125 | }, 126 | }; 127 | 128 | return update(state, changes); 129 | } 130 | 131 | function doSwipe (state, swipe) { 132 | const swipes = getCoincidentSwipes(state.swipes); 133 | 134 | if (swipes.length === 0) { 135 | return addSwipe(state, swipes, swipe); 136 | } 137 | 138 | const swipeA = swipe; 139 | const clientA = state.clients[swipeA.id]; 140 | const swipeB = swipes[0]; 141 | const clientB = state.clients[swipeB.id]; 142 | 143 | if (clientA.clusterID === clientB.clusterID) { 144 | return clearSwipes(state); 145 | } 146 | 147 | const { clients, clusters } = mergeAndRecalculateClusters(state, clientA, swipeA, clientB, swipeB); 148 | 149 | return update(state, { 150 | swipes: { $set: [] }, 151 | clients: { $set: clients }, 152 | clusters: { $set: clusters }, 153 | }); 154 | } 155 | 156 | function getCoincidentSwipes (swipes) { 157 | return _.filter(swipes, ({ timestamp }) => (Date.now() - timestamp) < SWIPE_DELAY_TOLERANCE); 158 | } 159 | 160 | function addSwipe (state, swipes, swipe) { 161 | const swipeWithTimestamp = update(swipe, { timestamp: { $set: Date.now() } }); 162 | 163 | return update(state, { 164 | swipes: { $set: swipes.concat([swipeWithTimestamp]) }, 165 | }); 166 | } 167 | 168 | function clearSwipes (state) { 169 | return update(state, { swipes: { $set: [] } }); 170 | } 171 | 172 | function mergeAndRecalculateClusters (state, clientA, swipeA, clientB, swipeB) { 173 | const transform = getTransform(clientA, swipeA, clientB, swipeB); 174 | 175 | return _.flow([ 176 | _.partial(mergeClusterData, _, transform, clientA.id, clientB.id), 177 | _.partial(moveClientsToNewCluster, _, transform, clientA.id, clientB.id), 178 | _.partial(recalculateOpenings, _, clientA.id, clientB.id), 179 | ])(state); 180 | } 181 | 182 | function mergeClusterData (state, transform, clientAID, clientBID) { 183 | const newClusterID = state.clients[clientAID].clusterID; 184 | const oldClusterID = state.clients[clientBID].clusterID; 185 | const clusterStateA = utils.getClusterState(state, newClusterID); 186 | const clusterStateB = utils.getClusterState(state, oldClusterID); 187 | const clusterDataChanges = config.cluster.events.merge(clusterStateA, clusterStateB, transform); 188 | 189 | return update(state, { 190 | clusters: { 191 | [newClusterID]: { data: clusterDataChanges }, 192 | }, 193 | }); 194 | } 195 | 196 | function moveClientsToNewCluster (state, transform, clientAID, clientBID) { 197 | const oldClusterID = state.clients[clientBID].clusterID; 198 | 199 | return update(state, { 200 | clusters: { $set: _.omit(state.clusters, oldClusterID) }, 201 | clients: _.assign( 202 | { 203 | [clientAID]: { 204 | adjacentClientIDs: { $push: [clientBID] }, 205 | }, 206 | }, 207 | getClientsBChanges(state, transform, clientAID, clientBID) 208 | ), 209 | }); 210 | } 211 | 212 | function getClientsBChanges (state, transform, clientAID, clientBID) { 213 | const newClusterID = state.clients[clientAID].clusterID; 214 | const oldClusterID = state.clients[clientBID].clusterID; 215 | 216 | const clientsInCluster = utils.getClientsInCluster(state.clients, oldClusterID); 217 | 218 | return _.reduce(clientsInCluster, (changes, client) => { 219 | /* eslint-disable no-param-reassign */ 220 | 221 | changes[client.id] = { 222 | clusterID: { $set: newClusterID }, 223 | transform: { 224 | $set: { 225 | x: client.transform.x + transform.x, 226 | y: client.transform.y + transform.y, 227 | }, 228 | }, 229 | }; 230 | 231 | if (client.id === clientBID) { 232 | changes[client.id].adjacentClientIDs = { $push: [clientAID] }; 233 | } 234 | 235 | return changes; 236 | /* eslint-enable no-param-reassign */ 237 | }, {}); 238 | } 239 | 240 | function recalculateOpenings (state, clientAID, clientBID) { 241 | return update(state, { 242 | clients: { 243 | [clientAID]: { 244 | openings: { $set: utils.getOpenings(state.clients, state.clients[clientAID]) }, 245 | }, 246 | [clientBID]: { 247 | openings: { $set: utils.getOpenings(state.clients, state.clients[clientBID]) }, 248 | }, 249 | }, 250 | }); 251 | } 252 | 253 | function getTransform (clientA, swipeA, clientB, swipeB) { 254 | switch (swipeA.direction) { 255 | case 'LEFT': 256 | return { 257 | x: (clientA.transform.x - clientB.size.width) - clientB.transform.x, 258 | y: (clientA.transform.y + (swipeA.position.y - swipeB.position.y)) - clientB.transform.y, 259 | }; 260 | case 'RIGHT': 261 | return { 262 | x: (clientA.transform.x + clientA.size.width) - clientB.transform.x, 263 | y: (clientA.transform.y + (swipeA.position.y - swipeB.position.y)) - clientB.transform.y, 264 | }; 265 | 266 | case 'UP': 267 | return { 268 | x: (clientA.transform.x + (swipeA.position.x - swipeB.position.x)) - clientB.transform.x, 269 | y: (clientA.transform.y - clientB.size.height) - clientB.transform.y, 270 | }; 271 | 272 | case 'DOWN': 273 | return { 274 | x: (clientA.transform.x + (swipeA.position.x - swipeB.position.x)) - clientB.transform.x, 275 | y: (clientA.transform.y + clientA.size.height) - clientB.transform.y, 276 | }; 277 | 278 | default: 279 | throw new Error(`Invalid direction: ${swipeA.direction}`); 280 | } 281 | } 282 | 283 | function disconnect (state, { id }) { 284 | const { clients, clusters } = state; 285 | const client = clients[id]; 286 | 287 | if (!client) { 288 | return state; 289 | } 290 | 291 | const clusterID = client.clusterID; 292 | 293 | return update(state, { 294 | clusters: { $set: removeEmptyCluster(clusters, clients, clusterID) }, 295 | clients: { $set: removeClient(clients, client) }, 296 | }); 297 | } 298 | 299 | function removeEmptyCluster (clusters, clients, clusterID) { 300 | if (utils.getClientsInCluster(clients, clusterID).length > 1) { 301 | return clusters; 302 | } 303 | 304 | return _.omit(clusters, [clusterID]); 305 | } 306 | 307 | function removeClient (clients, client) { 308 | return _(clients) 309 | .omit(client.id) 310 | .mapValues((other) => { 311 | const newAdjacentClientIDs = _.without(other.adjacentClientIDs, client.id); 312 | const newClient = update(other, { 313 | adjacentClientIDs: { $set: newAdjacentClientIDs }, 314 | }); 315 | 316 | return update(other, { 317 | openings: { $set: utils.getOpenings(clients, newClient) }, 318 | adjacentClientIDs: { $set: newAdjacentClientIDs }, 319 | }); 320 | }) 321 | .value(); 322 | } 323 | 324 | function reconnect (state, { id, size }) { 325 | return _.flow([ 326 | _.partial(disconnect, _, { id }), 327 | _.partial(connect, _, { id, size }), 328 | ])(state); 329 | } 330 | } 331 | 332 | module.exports = createReducer; 333 | -------------------------------------------------------------------------------- /src/server/utils.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | function getClientState (state, clientID) { 4 | const client = state.clients[clientID]; 5 | 6 | if (_.isNil(client.clusterID)) { 7 | return { client }; 8 | } 9 | 10 | return { 11 | client, 12 | cluster: getClusterState(state, client.clusterID), 13 | }; 14 | } 15 | 16 | function getOpenings (clients, client) { 17 | const { transform, size, adjacentClientIDs } = client; 18 | const adjacentClients = lookupIDs(clients, adjacentClientIDs); 19 | const holes = { 20 | left: [], 21 | top: [], 22 | right: [], 23 | bottom: [], 24 | }; 25 | 26 | adjacentClients.forEach((adjacentClient) => { 27 | const alignment = getAlignment(client, adjacentClient); 28 | const diffY = Math.abs(transform.y - adjacentClient.transform.y); 29 | const diffX = Math.abs(transform.x - adjacentClient.transform.x); 30 | 31 | switch (alignment) { 32 | case 'LEFT': 33 | if (transform.y < adjacentClient.transform.y 34 | && size.height > (adjacentClient.size.height + diffY)) { 35 | holes.right.push({ 36 | start: diffY, 37 | end: diffY + adjacentClient.size.height, 38 | }); 39 | } else if (transform.y > adjacentClient.transform.y) { 40 | holes.right.push({ 41 | start: 0, 42 | end: adjacentClient.size.height - diffY, 43 | }); 44 | } else { 45 | holes.right.push({ 46 | start: diffY, 47 | end: size.height, 48 | }); 49 | } 50 | break; 51 | 52 | case 'RIGHT': 53 | if (transform.y < adjacentClient.transform.y 54 | && size.height > (adjacentClient.size.height + diffY)) { 55 | holes.left.push({ 56 | start: diffY, 57 | end: diffY + adjacentClient.size.height, 58 | }); 59 | } else if (transform.y > adjacentClient.transform.y) { 60 | holes.left.push({ 61 | start: 0, 62 | end: adjacentClient.size.height - diffY, 63 | }); 64 | } else { 65 | holes.left.push({ 66 | start: diffY, 67 | end: size.height, 68 | }); 69 | } 70 | break; 71 | 72 | case 'TOP': 73 | if (transform.x < adjacentClient.transform.x 74 | && size.width > (adjacentClient.size.width + diffX)) { 75 | holes.bottom.push({ 76 | start: diffX, 77 | end: adjacentClient.size.width + diffX, 78 | }); 79 | } else if (transform.x > adjacentClient.transform.x) { 80 | holes.bottom.push({ 81 | start: 0, 82 | end: adjacentClient.size.width - diffX, 83 | }); 84 | } else { 85 | holes.bottom.push({ 86 | start: diffX, 87 | end: size.width, 88 | }); 89 | } 90 | break; 91 | 92 | case 'BOTTOM': 93 | if (transform.x < adjacentClient.transform.x 94 | && size.width > (adjacentClient.size.width + diffX)) { 95 | holes.top.push({ 96 | start: diffX, 97 | end: adjacentClient.size.width + diffX, 98 | }); 99 | } else if (transform.x > adjacentClient.transform.x) { 100 | holes.top.push({ 101 | start: 0, 102 | end: adjacentClient.size.width - diffX, 103 | }); 104 | } else { 105 | holes.top.push({ 106 | start: diffX, 107 | end: size.width, 108 | }); 109 | } 110 | break; 111 | 112 | default: 113 | throw new Error(`Invalid alignment ${alignment}`); 114 | } 115 | }); 116 | 117 | return holes; 118 | } 119 | 120 | function getAlignment (client1, client2) { 121 | const combClient1Width = (client1.transform.x + client1.size.width); 122 | const combClient1Height = (client1.transform.y + client1.size.height); 123 | 124 | const combClient2Width = (client2.transform.x + client2.size.width); 125 | const combClient2Height = (client2.transform.y + client2.size.height); 126 | 127 | if (client2.transform.x >= combClient1Width 128 | || almostEqual(client2.transform.x, combClient1Width)) { 129 | return 'LEFT'; 130 | } else if (client1.transform.x >= combClient2Width 131 | || almostEqual(client1.transform.x, combClient2Width)) { 132 | return 'RIGHT'; 133 | } else if (client2.transform.y >= combClient1Height 134 | || almostEqual(client2.transform.y, combClient1Height)) { 135 | return 'TOP'; 136 | } else if (client1.transform.y >= combClient2Height 137 | || almostEqual(client1.transform.y, combClient2Height)) { 138 | return 'BOTTOM'; 139 | } 140 | 141 | throw new Error('Unexpected placement of devices'); 142 | } 143 | 144 | function getClusterState ({ clusters, clients }, clusterID) { 145 | const cluster = clusters[clusterID]; 146 | 147 | return { 148 | id: cluster.id, 149 | data: cluster.data, 150 | clients: getClientsInCluster(clients, clusterID), 151 | }; 152 | } 153 | 154 | function getClientsInCluster (clients, clusterID) { 155 | if (_.isNil(clusterID)) { 156 | return []; 157 | } 158 | 159 | return _.filter(clients, (client) => client.clusterID === clusterID); 160 | } 161 | 162 | function lookupIDs (objects, objectIDs) { 163 | return _.map(objectIDs, (objectID) => objects[objectID]); 164 | } 165 | 166 | function almostEqual (a, b) { 167 | return Math.abs(a - b) < 0.1; 168 | } 169 | 170 | module.exports = { 171 | getOpenings, 172 | getClientsInCluster, 173 | getClusterState, 174 | getClientState, 175 | }; 176 | -------------------------------------------------------------------------------- /test/reducer.js: -------------------------------------------------------------------------------- 1 | /* global describe it beforeEach */ 2 | const _ = require('lodash'); 3 | /*eslint-disable*/ 4 | const should = require('should'); 5 | const sinon = require('sinon'); 6 | require('should-sinon'); 7 | /*eslint-enable*/ 8 | const update = require('immutability-helper'); 9 | const createReducer = require('../src/server/reducer'); 10 | const actions = require('../src/server/actions'); 11 | 12 | 13 | function getNoopReducer () { 14 | return createReducer({ 15 | client: { 16 | init: () => ({}), 17 | events: { 18 | update: () => ({}), 19 | }, 20 | }, 21 | cluster: { 22 | init: () => ({}), 23 | events: { 24 | update: () => ({}), 25 | merge: () => ({}), 26 | }, 27 | }, 28 | }); 29 | } 30 | 31 | describe('reducer', () => { 32 | describe('NEXT_STATE', () => { 33 | const initialState = { 34 | clusters: { 35 | A: { id: 'A', data: { counter: 10 } }, 36 | }, 37 | clients: { 38 | a: { id: 'a', data: { counter: 0 }, clusterID: 'A' }, 39 | b: { id: 'b', data: { counter: 2 }, clusterID: 'A' }, 40 | }, 41 | }; 42 | 43 | const expectedCluster = { 44 | clients: [ 45 | { clusterID: 'A', data: { counter: 0 }, id: 'a' }, 46 | { clusterID: 'A', data: { counter: 2 }, id: 'b' }, 47 | ], 48 | data: { counter: 10 }, 49 | id: 'A', 50 | }; 51 | 52 | function createUpdateReducer ({ updateClient, updateCluster }) { 53 | return createReducer({ 54 | client: { 55 | events: { 56 | update: updateClient, 57 | }, 58 | }, 59 | 60 | cluster: { 61 | events: { 62 | update: updateCluster, 63 | }, 64 | }, 65 | }); 66 | } 67 | 68 | it('should call client.events.update', () => { 69 | const update = sinon.spy(() => ({})); 70 | const reducer = createUpdateReducer({ 71 | updateClient: update, 72 | }); 73 | 74 | reducer(initialState, actions.nextState()); 75 | 76 | update.should.be.calledTwice(); 77 | update.getCall(0).args[0].should.eql({ 78 | cluster: expectedCluster, 79 | client: { 80 | id: 'a', 81 | data: { counter: 0 }, 82 | clusterID: 'A', 83 | }, 84 | }); 85 | update.getCall(1).args[0].should.eql({ 86 | cluster: expectedCluster, 87 | client: { 88 | id: 'b', 89 | data: { counter: 2 }, 90 | clusterID: 'A', 91 | }, 92 | }); 93 | }); 94 | 95 | it('should call cluster.events.update', () => { 96 | const update = sinon.spy(() => ({})); 97 | const reducer = createUpdateReducer({ 98 | updateCluster: update, 99 | }); 100 | 101 | reducer(initialState, actions.nextState()); 102 | 103 | update.should.be.calledOnce(); 104 | update.getCall(0).args[0].should.eql(expectedCluster); 105 | }); 106 | 107 | it('should update cluster state', () => { 108 | const reducer = createUpdateReducer({ 109 | updateCluster: (cluster) => ({ 110 | counter: { $set: cluster.data.counter + 1 }, 111 | }), 112 | }); 113 | 114 | const nextState = reducer(initialState, actions.nextState()); 115 | 116 | nextState.clusters.A.should.have.property('data').which.eql({ counter: 11 }); 117 | }); 118 | 119 | it('should update client state', () => { 120 | const reducer = createUpdateReducer({ 121 | updateClient: ({ client }) => ({ 122 | counter: { $set: client.data.counter + 2 }, 123 | }), 124 | }); 125 | 126 | const nextState = reducer(initialState, actions.nextState()); 127 | 128 | nextState.clients.a.should.have.property('data').which.eql({ counter: 2 }); 129 | nextState.clients.b.should.have.property('data').which.eql({ counter: 4 }); 130 | }); 131 | 132 | it('should update client state and cluster state combined', () => { 133 | const reducer = createUpdateReducer({ 134 | updateCluster: (cluster) => ({ 135 | counter: { $set: cluster.data.counter + 1 }, 136 | }), 137 | updateClient: ({ client }) => ({ 138 | counter: { $set: client.data.counter + 2 }, 139 | }), 140 | }); 141 | 142 | const nextState = reducer(initialState, actions.nextState()); 143 | 144 | nextState.clients.a.should.have.property('data').which.eql({ counter: 2 }); 145 | nextState.clients.b.should.have.property('data').which.eql({ counter: 4 }); 146 | nextState.clusters.A.should.have.property('data').which.eql({ counter: 11 }); 147 | }); 148 | }); 149 | 150 | describe('CONNECT', () => { 151 | const state = { 152 | clusters: {}, 153 | clients: {}, 154 | }; 155 | 156 | let newState; 157 | let reducer; 158 | let initClient; 159 | let initCluster; 160 | let clusterID; 161 | let expectedClient; 162 | 163 | beforeEach(() => { 164 | initClient = sinon.spy(() => ({ x: 'client' })); 165 | initCluster = sinon.spy(() => ({ x: 'cluster' })); 166 | reducer = createReducer({ 167 | client: { init: initClient }, 168 | cluster: { init: initCluster }, 169 | }); 170 | newState = reducer(state, actions.connect('a', { size: { width: 200, height: 300 } })); 171 | clusterID = _.keys(newState.clusters)[0]; 172 | expectedClient = { 173 | id: 'a', 174 | clusterID, 175 | size: { width: 200, height: 300 }, 176 | transform: { x: 0, y: 0 }, 177 | adjacentClientIDs: [], 178 | openings: { 179 | top: [], 180 | bottom: [], 181 | left: [], 182 | right: [], 183 | }, 184 | }; 185 | }); 186 | 187 | it('should call initClient', () => { 188 | initClient.getCall(0).args[0].should.eql(expectedClient); 189 | initClient.should.be.calledOnce(); 190 | }); 191 | 192 | it('should call initCluster', () => { 193 | initCluster.getCall(0).args[0].should.eql(expectedClient); 194 | initCluster.should.be.calledOnce(); 195 | }); 196 | 197 | it('should add player with new cluster', () => { 198 | 199 | 200 | newState.should.eql({ 201 | clusters: { 202 | [clusterID]: { id: clusterID, data: { x: 'cluster' } }, 203 | }, 204 | clients: { 205 | a: _.assign({}, expectedClient, { data: { x: 'client' } }), 206 | }, 207 | }); 208 | }); 209 | }); 210 | 211 | describe('SWIPE', () => { 212 | let initialState; 213 | let manyClustersInitialState; 214 | let clientA; 215 | let clientB; 216 | let clientA2; 217 | let clientB2; 218 | let clientC; 219 | let clientD; 220 | let reducer; 221 | let merge; 222 | 223 | beforeEach(() => { 224 | merge = sinon.spy((cluster, otherCluster) => ({ 225 | sum: { $set: cluster.data.sum + otherCluster.data.sum }, 226 | })); 227 | 228 | clientA = { 229 | id: 'a', 230 | clusterID: 'A', 231 | transform: { x: 0, y: 0 }, 232 | size: { width: 100, height: 100 }, 233 | adjacentClientIDs: [], 234 | data: {}, 235 | }; 236 | 237 | clientB = { 238 | id: 'b', 239 | clusterID: 'B', 240 | transform: { x: 0, y: 0 }, 241 | size: { width: 100, height: 100 }, 242 | adjacentClientIDs: [], 243 | data: {}, 244 | }; 245 | 246 | clientA2 = update(clientA, { adjacentClientIDs: { $push: ['c'] } }); 247 | clientB2 = update(clientB, { adjacentClientIDs: { $push: ['d'] } }); 248 | 249 | clientC = { 250 | id: 'c', 251 | clusterID: 'A', 252 | transform: { x: -100, y: 20 }, 253 | size: { width: 100, height: 200 }, 254 | adjacentClientIDs: ['a'], 255 | data: {}, 256 | }; 257 | 258 | clientD = { 259 | id: 'd', 260 | clusterID: 'B', 261 | transform: { x: 100, y: -50 }, 262 | size: { width: 100, height: 200 }, 263 | adjacentClientIDs: ['b'], 264 | data: {}, 265 | }; 266 | 267 | initialState = { 268 | clusters: { 269 | A: { id: 'A', data: { sum: 2 } }, 270 | B: { id: 'B', data: { sum: 3 } }, 271 | }, 272 | clients: { 273 | a: clientA, 274 | b: clientB, 275 | }, 276 | }; 277 | 278 | manyClustersInitialState = { 279 | clusters: initialState.clusters, 280 | clients: { 281 | a: clientA2, 282 | b: clientB2, 283 | c: clientC, 284 | d: clientD, 285 | }, 286 | }; 287 | 288 | reducer = createReducer({ 289 | client: { init: () => {} }, 290 | cluster: { 291 | init: () => {}, 292 | events: { merge }, 293 | }, 294 | }); 295 | }); 296 | 297 | describe('swipe handling', () => { 298 | it('should save first swipe', () => { 299 | const state = reducer(initialState, actions.swipe('a', { direction: 'LEFT', position: { x: 0, y: 20 } })); 300 | 301 | state.should.have.property('swipes').which.eql([{ 302 | direction: 'LEFT', 303 | id: 'a', 304 | position: { 305 | x: 0, 306 | y: 20, 307 | }, 308 | timestamp: state.swipes[0].timestamp, 309 | }]); 310 | }); 311 | 312 | it('should only save latest swipe if delay is too big', (done) => { 313 | const state1 = reducer(initialState, actions.swipe('a', { direction: 'LEFT', position: { x: 0, y: 20 } })); 314 | 315 | setTimeout(() => { 316 | const state2 = reducer(state1, actions.swipe('b', { direction: 'RIGHT', position: { x: 100, y: 20 } })); 317 | 318 | state2.should.have.property('swipes').which.eql([{ 319 | direction: 'RIGHT', 320 | id: 'b', 321 | position: { 322 | x: 100, 323 | y: 20, 324 | }, 325 | timestamp: state2.swipes[0].timestamp, 326 | }]); 327 | 328 | done(); 329 | }, 100); 330 | }); 331 | }); 332 | 333 | describe('merge of two clusters', () => { 334 | let state1; 335 | let state2; 336 | 337 | beforeEach(() => { 338 | state1 = reducer(initialState, actions.swipe('a', { direction: 'RIGHT', position: { x: 100, y: 20 } })); 339 | state2 = reducer(state1, actions.swipe('b', { direction: 'LEFT', position: { x: 0, y: 20 } })); 340 | }); 341 | 342 | it('should remove second cluster', () => { 343 | state2.should.not.have.propertyByPath('clusters', 'A'); 344 | }); 345 | 346 | it('should update adjacentClientIDs in each client', () => { 347 | state2.should.have.propertyByPath('clients', 'a', 'adjacentClientIDs').which.eql(['b']); 348 | state2.should.have.propertyByPath('clients', 'b', 'adjacentClientIDs').which.eql(['a']); 349 | }); 350 | 351 | it('should recalculate transform of joined client', () => { 352 | state2.should.have.propertyByPath('clients', 'a', 'transform').which.eql({ x: -100, y: 0 }); 353 | }); 354 | 355 | it('should update clusterID of joined client', () => { 356 | state2.should.have.propertyByPath('clients', 'a', 'clusterID').which.eql('B'); 357 | }); 358 | 359 | it('should call merge handler with both clusters and transform', () => { 360 | merge.should.be.calledOnce(); 361 | merge.getCall(0).args.should.eql([ 362 | { data: { sum: 3 }, id: 'B', clients: [clientB] }, 363 | { data: { sum: 2 }, id: 'A', clients: [clientA] }, 364 | { x: -100, y: 0 }, 365 | ]); 366 | }); 367 | 368 | it('should merge state', () => { 369 | state2.should.have.propertyByPath('clusters', 'B', 'data').which.eql({ sum: 5 }); 370 | }); 371 | 372 | it('should recalculate openings', () => { 373 | state2.should.have.propertyByPath('clients', 'a', 'openings').which.eql({ 374 | bottom: [], 375 | left: [], 376 | right: [{ end: 100, start: 0 }], 377 | top: [], 378 | }); 379 | state2.should.have.propertyByPath('clients', 'b', 'openings').which.eql({ 380 | bottom: [], 381 | left: [{ end: 100, start: 0 }], 382 | right: [], 383 | top: [], 384 | }); 385 | }); 386 | }); 387 | 388 | describe('merge of two clusters with each having multiple clients', () => { 389 | let state1; 390 | let state2; 391 | 392 | beforeEach(() => { 393 | state1 = reducer(manyClustersInitialState, actions.swipe('a', { 394 | direction: 'RIGHT', 395 | position: { x: 100, y: 20 }, 396 | })); 397 | state2 = reducer(state1, actions.swipe('b', { direction: 'LEFT', position: { x: 0, y: 20 } })); 398 | }); 399 | 400 | it('should remove second cluster', () => { 401 | state2.should.not.have.propertyByPath('clusters', 'A'); 402 | }); 403 | 404 | it('should update adjacentClientIDs in each client', () => { 405 | state2.should.have.propertyByPath('clients', 'a', 'adjacentClientIDs') 406 | .which.containEql('b') 407 | .which.containEql('c') 408 | .which.have.length(2); 409 | 410 | state2.should.have.propertyByPath('clients', 'b', 'adjacentClientIDs') 411 | .which.containEql('a') 412 | .which.containEql('d') 413 | .which.have.length(2); 414 | }); 415 | 416 | it('should recalculate transform of joined client', () => { 417 | state2.should.have.propertyByPath('clients', 'a', 'transform').which.eql({ x: -100, y: 0 }); 418 | state2.should.have.propertyByPath('clients', 'c', 'transform').which.eql({ x: -200, y: 20 }); 419 | state2.should.have.propertyByPath('clients', 'b', 'transform').which.eql({ x: 0, y: 0 }); 420 | state2.should.have.propertyByPath('clients', 'd', 'transform').which.eql({ x: 100, y: -50 }); 421 | }); 422 | 423 | it('should update clusterID of joined client', () => { 424 | state2.should.have.propertyByPath('clients', 'a', 'clusterID').which.eql('B'); 425 | state2.should.have.propertyByPath('clients', 'c', 'clusterID').which.eql('B'); 426 | }); 427 | 428 | it('should call merge handler with both clusters and transform', () => { 429 | merge.should.be.calledOnce(); 430 | merge.getCall(0).args.should.eql([ 431 | { data: { sum: 3 }, id: 'B', clients: [clientB2, clientD] }, 432 | { data: { sum: 2 }, id: 'A', clients: [clientA2, clientC] }, 433 | { x: -100, y: 0 }, 434 | ]); 435 | }); 436 | 437 | it('should merge state', () => { 438 | state2.should.have.propertyByPath('clusters', 'B', 'data').which.eql({ sum: 5 }); 439 | }); 440 | 441 | it('should recalculate openings', () => { 442 | state2.should.have.propertyByPath('clients', 'a', 'openings').which.eql({ 443 | bottom: [], 444 | left: [{ end: 100, start: 20 }], 445 | right: [{ end: 100, start: 0 }], 446 | top: [], 447 | }); 448 | state2.should.have.propertyByPath('clients', 'b', 'openings').which.eql({ 449 | bottom: [], 450 | left: [{ end: 100, start: 0 }], 451 | right: [{ end: 150, start: 0 }], 452 | top: [], 453 | }); 454 | }); 455 | }); 456 | }); 457 | 458 | describe('RECONNECT', () => { 459 | let initialState; 460 | let nextState; 461 | let reducer; 462 | 463 | beforeEach(() => { 464 | reducer = getNoopReducer(); 465 | initialState = { 466 | clusters: { 467 | A: { id: 'A', data: { counter: 10 } }, 468 | }, 469 | clients: { 470 | a: { 471 | id: 'a', 472 | data: { counter: 0 }, 473 | clusterID: 'A', 474 | size: { width: 100, height: 200 }, 475 | }, 476 | }, 477 | }; 478 | 479 | nextState = reducer(initialState, actions.reconnect('a', { size: { width: 200, height: 100 } })); 480 | }); 481 | 482 | it('should assign client to new cluster', () => { 483 | nextState.should.have.propertyByPath('clients', 'a', 'clusterID').which.not.eql('A'); 484 | nextState.should.have.propertyByPath('clusters', nextState.clients.a.clusterID); 485 | }); 486 | 487 | it('should update client size', () => { 488 | nextState.should.have.propertyByPath('clients', 'a', 'size').which.eql({ width: 200, height: 100 }); 489 | }); 490 | }); 491 | }); 492 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /* global describe it beforeEach */ 2 | /*eslint-disable*/ 3 | const should = require('should'); 4 | const utils = require('../src/server/utils'); 5 | /*eslint-enable*/ 6 | 7 | 8 | describe('utils', () => { 9 | describe('getOpenings', () => { 10 | let clientA; 11 | let clientB; 12 | let clientC; 13 | let clientD; 14 | let clientE; 15 | let state; 16 | 17 | describe('horizontal', () => { 18 | beforeEach(() => { 19 | clientA = { 20 | id: 'a', 21 | clusterID: 'A', 22 | adjacentClientIDs: [], 23 | transform: { x: 0, y: 0 }, 24 | size: { width: 300, height: 600 }, 25 | data: {}, 26 | }; 27 | 28 | clientB = { 29 | id: 'b', 30 | clusterID: 'A', 31 | adjacentClientIDs: [], 32 | transform: { x: 350, y: -100 }, 33 | size: { width: 250, height: 300 }, 34 | data: {}, 35 | }; 36 | 37 | clientC = { 38 | id: 'c', 39 | clusterID: 'A', 40 | adjacentClientIDs: [], 41 | transform: { x: -300, y: -200 }, 42 | size: { width: 250, height: 400 }, 43 | data: {}, 44 | }; 45 | 46 | clientD = { 47 | id: 'd', 48 | clusterID: 'A', 49 | adjacentClientIDs: [], 50 | transform: { x: 350, y: 100 }, 51 | size: { width: 200, height: 100 }, 52 | data: {}, 53 | }; 54 | 55 | clientE = { 56 | id: 'e', 57 | clusterID: 'A', 58 | adjacentClientIDs: [], 59 | transform: { x: -300, y: 200 }, 60 | size: { width: 250, height: 350 }, 61 | data: {}, 62 | }; 63 | 64 | state = { 65 | clusters: { 1: {} }, 66 | clients: { 67 | a: clientA, 68 | b: clientB, 69 | c: clientC, 70 | d: clientD, 71 | e: clientE, 72 | }, 73 | }; 74 | }); 75 | 76 | it('should return the holes - connected lowerLeft to upperRight', () => { 77 | clientA.adjacentClientIDs.push('b'); 78 | clientB.adjacentClientIDs.push('a'); 79 | 80 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 81 | const holesClientB = utils.getOpenings(state.clients, state.clients.b); 82 | 83 | holesClientA.should.eql({ 84 | left: [], 85 | top: [], 86 | right: [{ start: 0, end: 200 }], 87 | bottom: [], 88 | }); 89 | 90 | holesClientB.should.eql({ 91 | left: [{ start: 100, end: 300 }], 92 | top: [], 93 | right: [], 94 | bottom: [], 95 | }); 96 | }); 97 | 98 | it('should return the holes - connected upperLeft to lowerRight', () => { 99 | clientC.adjacentClientIDs.push('a'); 100 | clientA.adjacentClientIDs.push('c'); 101 | 102 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 103 | const holesClientC = utils.getOpenings(state.clients, state.clients.c); 104 | 105 | holesClientA.should.eql({ 106 | left: [{ start: 0, end: 200 }], 107 | top: [], 108 | right: [], 109 | bottom: [], 110 | }); 111 | 112 | holesClientC.should.eql({ 113 | left: [], 114 | top: [], 115 | right: [{ start: 200, end: 400 }], 116 | bottom: [], 117 | }); 118 | }); 119 | 120 | it('should return the holes - connected bigLeft to smallRight', () => { 121 | clientA.adjacentClientIDs.push('d'); 122 | clientD.adjacentClientIDs.push('a'); 123 | 124 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 125 | const holesClientD = utils.getOpenings(state.clients, state.clients.d); 126 | 127 | holesClientA.should.eql({ 128 | left: [], 129 | top: [], 130 | right: [{ start: 100, end: 200 }], 131 | bottom: [], 132 | }); 133 | 134 | holesClientD.should.eql({ 135 | left: [{ start: 0, end: 500 }], 136 | top: [], 137 | right: [], 138 | bottom: [], 139 | }); 140 | }); 141 | 142 | it('should return the holes - connected smallLeft to bigRight', () => { 143 | clientA.adjacentClientIDs.push('e'); 144 | clientE.adjacentClientIDs.push('a'); 145 | 146 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 147 | const holesClientE = utils.getOpenings(state.clients, state.clients.e); 148 | 149 | holesClientA.should.eql({ 150 | left: [{ start: 200, end: 550 }], 151 | top: [], 152 | right: [], 153 | bottom: [], 154 | }); 155 | 156 | holesClientE.should.eql({ 157 | left: [], 158 | top: [], 159 | right: [{ start: 0, end: 400 }], 160 | bottom: [], 161 | }); 162 | }); 163 | }); 164 | 165 | describe('vertical', () => { 166 | beforeEach(() => { 167 | clientA = { 168 | id: 'a', 169 | clusterID: 'A', 170 | adjacentClientIDs: [], 171 | transform: { x: 0, y: 0 }, 172 | size: { width: 400, height: 600 }, 173 | data: {}, 174 | }; 175 | 176 | clientB = { 177 | id: 'b', 178 | clusterID: 'A', 179 | adjacentClientIDs: [], 180 | transform: { x: 350, y: -350 }, 181 | size: { width: 250, height: 300 }, 182 | data: {}, 183 | }; 184 | 185 | clientC = { 186 | id: 'c', 187 | clusterID: 'A', 188 | adjacentClientIDs: [], 189 | transform: { x: -300, y: -450 }, 190 | size: { width: 450, height: 400 }, 191 | data: {}, 192 | }; 193 | 194 | clientD = { 195 | id: 'd', 196 | clusterID: 'A', 197 | adjacentClientIDs: [], 198 | transform: { x: 100, y: -350 }, 199 | size: { width: 200, height: 300 }, 200 | data: {}, 201 | }; 202 | 203 | clientE = { 204 | id: 'e', 205 | clusterID: 'A', 206 | adjacentClientIDs: [], 207 | transform: { x: -100, y: 650 }, 208 | size: { width: 800, height: 350 }, 209 | data: {}, 210 | }; 211 | 212 | state = { 213 | clusters: { 1: {} }, 214 | clients: { 215 | a: clientA, 216 | b: clientB, 217 | c: clientC, 218 | d: clientD, 219 | e: clientE, 220 | }, 221 | }; 222 | }); 223 | 224 | it('should return the holes - connected rightTop to leftBottom', () => { 225 | clientA.adjacentClientIDs.push('b'); 226 | clientB.adjacentClientIDs.push('a'); 227 | 228 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 229 | const holesClientB = utils.getOpenings(state.clients, state.clients.b); 230 | 231 | holesClientA.should.eql({ 232 | left: [], 233 | top: [{ start: 350, end: 400 }], 234 | right: [], 235 | bottom: [], 236 | }); 237 | 238 | holesClientB.should.eql({ 239 | left: [], 240 | top: [], 241 | right: [], 242 | bottom: [{ start: 0, end: 50 }], 243 | }); 244 | }); 245 | 246 | it('should return the holes - connected leftTop to rightBottom', () => { 247 | clientA.adjacentClientIDs.push('c'); 248 | clientC.adjacentClientIDs.push('a'); 249 | 250 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 251 | const holesClientC = utils.getOpenings(state.clients, state.clients.c); 252 | 253 | holesClientA.should.eql({ 254 | left: [], 255 | top: [{ start: 0, end: 150 }], 256 | right: [], 257 | bottom: [], 258 | }); 259 | 260 | holesClientC.should.eql({ 261 | left: [], 262 | top: [], 263 | right: [], 264 | bottom: [{ start: 300, end: 450 }], 265 | }); 266 | }); 267 | 268 | it('should return the holes - connected smallTop to largeBottom', () => { 269 | clientA.adjacentClientIDs.push('d'); 270 | clientD.adjacentClientIDs.push('a'); 271 | 272 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 273 | const holesClientD = utils.getOpenings(state.clients, state.clients.d); 274 | 275 | holesClientA.should.eql({ 276 | left: [], 277 | top: [{ start: 100, end: 300 }], 278 | right: [], 279 | bottom: [], 280 | }); 281 | 282 | holesClientD.should.eql({ 283 | left: [], 284 | top: [], 285 | right: [], 286 | bottom: [{ start: 0, end: 300 }], 287 | }); 288 | }); 289 | 290 | it('should return the holes - connected largeTop to smallBottom', () => { 291 | clientA.adjacentClientIDs.push('e'); 292 | clientE.adjacentClientIDs.push('a'); 293 | 294 | const holesClientA = utils.getOpenings(state.clients, state.clients.a); 295 | const holesClientE = utils.getOpenings(state.clients, state.clients.e); 296 | 297 | holesClientA.should.eql({ 298 | left: [], 299 | top: [], 300 | right: [], 301 | bottom: [{ start: 0, end: 700 }], 302 | }); 303 | 304 | holesClientE.should.eql({ 305 | left: [], 306 | top: [{ start: 100, end: 500 }], 307 | right: [], 308 | bottom: [], 309 | }); 310 | }); 311 | }); 312 | }); 313 | }); 314 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: [ 5 | './src/client/index.js', 6 | ], 7 | output: { 8 | path: path.join(__dirname, 'dist'), 9 | filename: 'bundle.js', 10 | publicPath: '/', 11 | }, 12 | module: { 13 | loaders: [ 14 | { 15 | test: /\.js$/, 16 | exclude: /(node_modules|bower_components)/, 17 | loader: 'babel-loader', // It seems that we need to use babel-loader 18 | }, 19 | { 20 | test: /\.css$/, 21 | loader: 'style-loader!css-loader', 22 | }, 23 | ], 24 | }, 25 | }; 26 | --------------------------------------------------------------------------------