diff --git a/README.md b/README.md index 3e5c4ab..40e3727 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,20 @@ https://occupapp.now.sh/ ## Versions -Current version is deployed at https://occupapp.now.sh/, and follows the [master branch](https://github.com/LyonDataViz/occupapp/tree/master). +Current version is deployed at https://occupapp.now.sh/, and follows the +[master branch](https://github.com/LyonDataViz/occupapp/tree/master). Previous versions: -- Occupapp v1.2.1 is deployed at https://occupapp-exggft5q9.now.sh/, and corresponds to the [tag v1.2.1](https://github.com/LyonDataViz/occupapp/tree/v1.2.1). -- Occupapp v1.2.0 is deployed at https://occupapp-khxo9ktm7.now.sh/, and corresponds to the [tag v1.2.0](https://github.com/LyonDataViz/occupapp/tree/v1.2.0). -- Occupapp v1.1.0 is deployed at https://occupapp-qf17v5yfl.now.sh/, and corresponds to the [tag v1.1.0](https://github.com/LyonDataViz/occupapp/tree/v1.1.0). +- Occupapp v1.2.1 is deployed at https://occupapp-exggft5q9.now.sh/, and + corresponds to the + [tag v1.2.1](https://github.com/LyonDataViz/occupapp/tree/v1.2.1). +- Occupapp v1.2.0 is deployed at https://occupapp-khxo9ktm7.now.sh/, and + corresponds to the + [tag v1.2.0](https://github.com/LyonDataViz/occupapp/tree/v1.2.0). +- Occupapp v1.1.0 is deployed at https://occupapp-qf17v5yfl.now.sh/, and + corresponds to the + [tag v1.1.0](https://github.com/LyonDataViz/occupapp/tree/v1.1.0). See [CHANGELOG.md](./CHANGELOG.md) for more details. @@ -72,7 +79,8 @@ Other targets: and then edit [CHANGELOG.md](./CHANGELOG.md) to add: - Online demo URL: see https://zeit.co/lyondataviz/occupapp/deployments - - Issues milestone (if corresponding): see https://github.com/LyonDataViz/occupapp/milestones + - Issues milestone (if corresponding): see + https://github.com/LyonDataViz/occupapp/milestones - Zip download: see https://github.com/LyonDataViz/occupapp/releases Build upon Vue.js, see [Configuration Reference](https://cli.vuejs.org/config/). @@ -100,7 +108,35 @@ The Vue components are developed as TypeScript classes. See: The Vuex stores are dynamic modules, and use Typescript. See https://championswimmer.in/vuex-module-decorators/. -## Documentation +## Development documentation + +The Vuex state is managed as follows. + +The URL query parameters are the source of trust, and define the current +composition. Note that if the URL doesn't contain any query parameters, or if +they are insufficient to recreate a complete composition, the application will +be routed to a new URL, with missing parameters filled with default values, +possibly several times, until converging to a valid URL. + +Once the URL has been parsed successfully, the current composition is stored in +store/compositions, that contains the compositions associated to all the images +(the default ones, and any other updated or linked image). Note that these +compositions are not persisted in the URL, except the current one. + +The Vuex state also contains other data that is not persisted in the URL: + +- derived data: (such as store/current/backgroundImage.ts). They don't contain + any data by their own, but react to current composition change by updating + their cached getters. They also provide helpers to update the current + composition (ie. to route to a new URL). They have access to the router. +- temporary data, such as the points selection used to delete various points at + once (it's cleared every time a composition is changed) +- cached data: store/current/backgroundImage.ts contains the background image, + and store/current/pointMetrics contains the points metrics that are computed + from the current composition +- general data, such as the color vs black and white switch for background image + +## Credits Project developed for the [LIRIS M2i project](https://projet.liris.cnrs.fr/mi2/) by Sylvain Lesage with Celia Gremillet, Philippe Rivière, Gabin Rolland, diff --git a/package-lock.json b/package-lock.json index 592ced3..78272d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1399,6 +1399,12 @@ "integrity": "sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==", "dev": true }, + "@types/socket.io-client": { + "version": "1.4.32", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.32.tgz", + "integrity": "sha512-Vs55Kq8F+OWvy1RLA31rT+cAyemzgm0EWNeax6BWF8H7QiiOYMJIdcwSDdm5LVgfEkoepsWkS+40+WNb7BUMbg==", + "dev": true + }, "@types/uuid": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.6.tgz", @@ -2262,6 +2268,11 @@ "integrity": "sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==", "dev": true }, + "after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=" + }, "aggregate-error": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", @@ -2472,6 +2483,11 @@ "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", "dev": true }, + "arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==" + }, "arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -2573,8 +2589,7 @@ "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" }, "asynckit": { "version": "0.4.0", @@ -2588,6 +2603,24 @@ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", "dev": true }, + "automerge": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/automerge/-/automerge-0.12.1.tgz", + "integrity": "sha512-7JOiRk4b6EP/Uj0AjmZTeYICXJmBRHFkL0U3mlTNXuDlUr3c4v/Wb8v0RXiX4UuVgGjkovcjOdiBMkVmzdu2KQ==", + "requires": { + "immutable": "^3.8.2", + "transit-immutable-js": "^0.7.0", + "transit-js": "^0.8.861", + "uuid": "3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } + }, "autoprefixer": { "version": "9.7.3", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.3.tgz", @@ -2727,6 +2760,11 @@ } } }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -2788,6 +2826,11 @@ } } }, + "base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" + }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -2817,6 +2860,14 @@ "voc": "^1.1.0" } }, + "better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=", + "requires": { + "callsite": "1.0.0" + } + }, "bfj": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/bfj/-/bfj-6.1.2.tgz", @@ -2856,6 +2907,11 @@ "file-uri-to-path": "1.0.0" } }, + "blob": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz", + "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" + }, "block-stream": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", @@ -3302,6 +3358,11 @@ "caller-callsite": "^2.0.0" } }, + "callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=" + }, "callsites": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", @@ -3897,12 +3958,22 @@ } } }, + "component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=" + }, "compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -5560,7 +5631,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", - "dev": true, "requires": { "ms": "^2.1.1" } @@ -6231,6 +6301,51 @@ "once": "^1.4.0" } }, + "engine.io-client": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.0.tgz", + "integrity": "sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==", + "requires": { + "component-emitter": "1.2.1", + "component-inherit": "0.0.3", + "debug": "~4.1.0", + "engine.io-parser": "~2.2.0", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "ws": "~6.1.0", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "ws": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz", + "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "engine.io-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz", + "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==", + "requires": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.5", + "blob": "0.0.5", + "has-binary2": "~1.0.2" + } + }, "enhanced-resolve": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.1.1.tgz", @@ -8471,6 +8586,26 @@ } } }, + "has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "requires": { + "isarray": "2.0.1" + }, + "dependencies": { + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" + } + } + }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=" + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -8868,6 +9003,11 @@ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=" + }, "import-cwd": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz", @@ -8930,6 +9070,11 @@ "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", "dev": true }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" + }, "infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", @@ -10781,8 +10926,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "multicast-dns": { "version": "6.2.3", @@ -11204,6 +11348,11 @@ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true }, + "object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=" + }, "object-copy": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", @@ -11709,6 +11858,22 @@ } } }, + "parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=", + "requires": { + "better-assert": "~1.0.0" + } + }, + "parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=", + "requires": { + "better-assert": "~1.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -13849,6 +14014,11 @@ "rechoir": "^0.6.2" } }, + "shvl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shvl/-/shvl-2.0.0.tgz", + "integrity": "sha512-WbpzSvI5XgVGJ3A4ySGe8hBxj0JgJktfnoLhhJmvITDdK21WPVWwgG8GPlYEh4xqdti3Ff7PJ5G0QrRAjNS0Ig==" + }, "sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", @@ -14017,6 +14187,69 @@ } } }, + "socket.io-client": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz", + "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==", + "requires": { + "backo2": "1.0.2", + "base64-arraybuffer": "0.1.5", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "~4.1.0", + "engine.io-client": "~3.4.0", + "has-binary2": "~1.0.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "socket.io-parser": "~3.3.0", + "to-array": "0.1.4" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + } + } + }, + "socket.io-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz", + "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==", + "requires": { + "component-emitter": "1.2.1", + "debug": "~3.1.0", + "isarray": "2.0.1" + }, + "dependencies": { + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=" + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "sockjs": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz", @@ -14869,6 +15102,11 @@ "os-tmpdir": "~1.0.2" } }, + "to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA=" + }, "to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", @@ -14962,6 +15200,16 @@ "punycode": "^2.1.0" } }, + "transit-immutable-js": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/transit-immutable-js/-/transit-immutable-js-0.7.0.tgz", + "integrity": "sha1-mT4lCJtjEf9AIUD1VidtbSUwBdk=" + }, + "transit-js": { + "version": "0.8.861", + "resolved": "https://registry.npmjs.org/transit-js/-/transit-js-0.8.861.tgz", + "integrity": "sha512-4O9OrYPZw6C0M5gMTvaeOp+xYz6EF79JsyxIvqXHlt+pisSrioJWFOE80N8aCPoJLcNaXF442RZrVtdmd4wkDQ==" + }, "trim-newlines": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", @@ -15607,6 +15855,27 @@ "resolved": "https://registry.npmjs.org/vuex-module-decorators/-/vuex-module-decorators-0.11.0.tgz", "integrity": "sha512-mQeH0F9C5eoDvhOxGLxc2eKAA+GT9MvCyauZZtQSvLjN3PX//oj9mlhpmMJH2/q9LOScZ8fBrX1qqd+aOKq/LA==" }, + "vuex-persistedstate": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/vuex-persistedstate/-/vuex-persistedstate-2.7.0.tgz", + "integrity": "sha512-mpko65DUMBY4mF4sSGsgrqjE7fwO373LFZeuNrC55glRuBBAK4dkgzjr4j4Bij7WtMoKuo2t2w0NGenjauISaQ==", + "requires": { + "deepmerge": "^4.2.2", + "shvl": "^2.0.0" + }, + "dependencies": { + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + } + } + }, + "vuex-router-sync": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vuex-router-sync/-/vuex-router-sync-5.0.0.tgz", + "integrity": "sha512-Mry2sO4kiAG64714X1CFpTA/shUH1DmkZ26DFDtwoM/yyx6OtMrc+MxrU+7vvbNLO9LSpgwkiJ8W+rlmRtsM+w==" + }, "w3c-hr-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", @@ -16190,6 +16459,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -16303,6 +16577,11 @@ "yargs": "^13.3.0" } }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=" + }, "yorkie": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yorkie/-/yorkie-2.0.0.tgz", diff --git a/package.json b/package.json index 75ce44e..deace26 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,20 @@ }, "dependencies": { "@handsontable/vue": "^4.1.1", + "automerge": "^0.12.1", "core-js": "^3.6.2", "d3": "^5.15.0", "d3-delaunay": "^5.1.6", "handsontable": "^7.3.0", + "socket.io-client": "^2.3.0", "uuid": "^3.3.3", "vue": "^2.6.11", "vue-router": "^3.0.6", "vuetify": "^2.2.1", "vuex": "^3.1.2", - "vuex-module-decorators": "^0.11.0" + "vuex-module-decorators": "^0.11.0", + "vuex-persistedstate": "^2.7.0", + "vuex-router-sync": "^5.0.0" }, "devDependencies": { "@mdi/font": "^4.7.95", @@ -39,6 +43,7 @@ "@types/d3": "^5.7.2", "@types/d3-delaunay": "^4.1.0", "@types/mocha": "^5.2.4", + "@types/socket.io-client": "^1.4.32", "@types/uuid": "^3.4.6", "@vue/cli-plugin-babel": "^4.1.2", "@vue/cli-plugin-eslint": "^4.1.2", diff --git a/src/App.vue b/src/App.vue index 97b3e5f..37d073a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,149 +1,16 @@ diff --git a/src/components/Background.vue b/src/components/Background.vue index 21fdcc9..ffb0b50 100644 --- a/src/components/Background.vue +++ b/src/components/Background.vue @@ -29,25 +29,20 @@ diff --git a/src/components/CollaborationPanel.vue b/src/components/CollaborationPanel.vue new file mode 100644 index 0000000..6934219 --- /dev/null +++ b/src/components/CollaborationPanel.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/components/FilterShadow2.vue b/src/components/FilterShadow2.vue index e175dcb..9dd717c 100644 --- a/src/components/FilterShadow2.vue +++ b/src/components/FilterShadow2.vue @@ -37,6 +37,5 @@ import Vue from 'vue' import Component from 'vue-class-component' @Component -export default class FilterShadow2 extends Vue { -} +export default class FilterShadow2 extends Vue {} diff --git a/src/components/FilterShadow8.vue b/src/components/FilterShadow8.vue index eefc66e..e83dd99 100644 --- a/src/components/FilterShadow8.vue +++ b/src/components/FilterShadow8.vue @@ -37,6 +37,5 @@ import Vue from 'vue' import Component from 'vue-class-component' @Component -export default class FilterShadow8 extends Vue { -} +export default class FilterShadow8 extends Vue {} diff --git a/src/components/Gallery.vue b/src/components/Gallery.vue index c270f1c..bc4d3f8 100644 --- a/src/components/Gallery.vue +++ b/src/components/Gallery.vue @@ -9,25 +9,21 @@ class="images-row" > - + - + mdi-check @@ -69,7 +65,7 @@ text-transform: uppercase; } .v-overlay { - opacity: 0 + opacity: 0; } .v-item--active .v-overlay, .v-overlay:hover, @@ -86,18 +82,13 @@ diff --git a/src/components/ImageCacheCanvas.vue b/src/components/ImageCacheCanvas.vue index cb0b10c..3f4c5e4 100644 --- a/src/components/ImageCacheCanvas.vue +++ b/src/components/ImageCacheCanvas.vue @@ -24,7 +24,7 @@ export default class ImageCacheCanvas extends Vue { // annotate refs type $refs!: { - canvas: HTMLCanvasElement, + canvas: HTMLCanvasElement } // computed @@ -47,9 +47,18 @@ export default class ImageCacheCanvas extends Vue { // methods drawCanvas (): void { - if (this.ctx === null) { return } + if (this.ctx === null) { + return + } // Redraw & reposition content - this.ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0) + this.ctx.setTransform( + this.devicePixelRatio, + 0, + 0, + this.devicePixelRatio, + 0, + 0 + ) this.ctx.clearRect(0, 0, this.width, this.height) if (this.image.width === 0 || this.image.height === 0) { // Show a white rectangle in case the image is empty @@ -73,7 +82,10 @@ export default class ImageCacheCanvas extends Vue { @Watch('width') @Watch('height') @Watch('devicePixelRatio') - onSomethingChange (val: boolean | number | HTMLImageElement, oldVal: boolean | number | HTMLImageElement) { + onSomethingChange ( + val: boolean | number | HTMLImageElement, + oldVal: boolean | number | HTMLImageElement + ) { // See https://stackoverflow.com/a/37588776/7351594 clearTimeout(this.debounceTimer) this.debounceTimer = window.setTimeout(() => { diff --git a/src/components/ImageUploaderButton.vue b/src/components/ImageUploaderButton.vue index 7fd8455..229616a 100644 --- a/src/components/ImageUploaderButton.vue +++ b/src/components/ImageUploaderButton.vue @@ -81,9 +81,7 @@ - + diff --git a/src/components/ImagesPanel.vue b/src/components/ImagesPanel.vue index 10be643..ee0cdb9 100644 --- a/src/components/ImagesPanel.vue +++ b/src/components/ImagesPanel.vue @@ -8,20 +8,15 @@ - + diff --git a/src/components/JsonBlock.vue b/src/components/JsonBlock.vue new file mode 100644 index 0000000..50dad1a --- /dev/null +++ b/src/components/JsonBlock.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/Home.vue b/src/components/Main.vue similarity index 64% rename from src/components/Home.vue rename to src/components/Main.vue index e46dc73..af4fb75 100644 --- a/src/components/Home.vue +++ b/src/components/Main.vue @@ -70,68 +70,57 @@ diff --git a/src/components/MainPanel.vue b/src/components/MainPanel.vue index 83ab5ef..f72d696 100644 --- a/src/components/MainPanel.vue +++ b/src/components/MainPanel.vue @@ -1,7 +1,5 @@ diff --git a/src/components/SocketGuests.vue b/src/components/SocketGuests.vue new file mode 100644 index 0000000..c1f139a --- /dev/null +++ b/src/components/SocketGuests.vue @@ -0,0 +1,83 @@ + + + + diff --git a/src/main.ts b/src/main.ts index dd710dd..fa84e3d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,13 @@ import Vue from 'vue' import App from './App.vue' import store from './store' +import router from './router' import vuetify from './plugins/vuetify' Vue.config.productionTip = false new Vue({ + router, store, vuetify, render: h => h(App) diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..673d0cf --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,28 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' +import Home from '../views/Home.vue' + +Vue.use(VueRouter) + +const routes = [ + { + path: '/', + name: 'home', + component: Home + } + // ,{ + // path: '/about', + // name: 'about', + // // route level code-splitting + // // this generates a separate chunk (about.[hash].js) for this route + // // which is lazy-loaded when the route is visited. + // component: () => + // import(/* webpackChunkName: "about" */ '../views/About.vue') + // } +] + +const router = new VueRouter({ + routes +}) + +export default router diff --git a/src/store/current/categories.ts b/src/store/current/categories.ts deleted file mode 100644 index a56af33..0000000 --- a/src/store/current/categories.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Current categories - -// See https://championswimmer.in/vuex-module-decorators/ -import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' -import uuid from 'uuid' -import * as d3 from 'd3' -import { Color, Category } from '@/utils/types.ts' - -const defaultId = uuid.v4() -const defaultColor = d3.rgb(0, 0, 0, 0.3) -const defaultPalette = [d3.rgb(255, 195, 8), d3.rgb(172, 159, 253), d3.rgb(181, 246, 66), d3.rgb(239, 106, 222)] -const defaultArray: Category[] = defaultPalette.map(c => { - return { id: uuid.v4(), color: c.hex() } -}) -const defaultMap: Map = new Map(defaultArray.map(c => { - return [c.id, c] -})) - -@Module({ dynamic: true, store, name: 'categories', namespaced: true }) -export default class Categories extends VuexModule { - // IMPORTANT. We use a hack to add Vue reactivity to Map and Set objects - // See https://stackoverflow.com/a/45441321/7351594 - - // State - state of truth - meant to be exported as a JSON - init definitions - list: Map = defaultMap - listChangeTracker: number = 1 - currentCategoryId: string = defaultId - - default: Category = { - id: defaultId, - // Note .hex() will be obsolete in d3-color, see https://github.com/d3/d3-color#color_formatHex and https://www.npmjs.com/package/@types/d3 - color: defaultColor.hex() - } - - // Getters - cached, not meant to be exported - get defaultId (): string { - return this.default.id - } - get defaultMap (): Map { - return defaultMap - } - get defaultArray (): Category[] { - return defaultArray - } - get asMap (): Map { - // By using `listChangeTracker` we tell Vue that this property depends on it, - // so it gets re-evaluated whenever `listChangeTracker` changes - HACK - return this.listChangeTracker ? this.list : this.list - } - get asArray (): Category[] { - return [...this.asMap.values()] - } - get size (): number { - return this.asMap.size - } - get get (): (id:string) => Category { - return (id:string): Category => this.asMap.get(id) || this.default - } - get keys (): IterableIterator { - return this.asMap.keys() - } - get keysArray (): string[] { - return [...this.keys] - } - // USE? - // get values (): IterableIterator { - // return this.asMap.values() - // } - // get has (): (id:string) => boolean { - // return (id:string): boolean => this.asMap.has(id) - // } - get getColor (): (id:string) => string { - return (id:string): string => this.get(id).color - } - - // Mutations (synchronous) - @Mutation - fromMap (list: Map) { - this.list = list - // Trigger Vue updates - this.listChangeTracker += 1 - } - @Mutation - set (category: Category) { - this.list.set(category.id, category) - this.listChangeTracker += 1 - } - @Mutation - delete (id: string) { - this.list.delete(id) - this.listChangeTracker += 1 - } - @Mutation - setCurrentCategoryId (id: string) { - this.currentCategoryId = id - } - - // Actions - @Action - fromArray (list: Category[]) { - this.fromMap(new Map(list.map(p => [p.id, p]))) - } - @Action - clear () { - this.fromMap(new Map()) - } - @Action - post (c: Color) { - const newCategory = { id: uuid.v4(), ...c } - this.set(newCategory) - } - @Action - deleteSet (ids: Set) { - const newList: Map = new Map(this.asMap) - for (const id of ids) { - newList.delete(id) - } - this.fromMap(newList) - } - @Action - setColor (id: string, color: string) { - const category = this.asMap.get(id) - if (category) { - this.set({ ...category, color }) - } else { - throw RangeError(`There is no category id=${id} in the list`) - } - } - // Used to assign a category by default - @Action - nextId (): string { - const keys = this.keysArray - if (keys.length === 0) { - return this.defaultId - } - const index = keys.indexOf(this.currentCategoryId) - const nextId = keys[(index + 1) % keys.length] - this.setCurrentCategoryId(nextId) - return nextId - } -} diff --git a/src/store/current/composition.ts b/src/store/current/composition.ts deleted file mode 100644 index cd75432..0000000 --- a/src/store/current/composition.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Current composition - -// See https://championswimmer.in/vuex-module-decorators/ -import { getModule, Action, Module, VuexModule } from 'vuex-module-decorators' -import store from '@/store' - -import { ExportableComposition } from '@/utils/types.ts' - -import BackgroundImage from '@/store/current/backgroundImage.ts' -import Categories from '@/store/current/categories.ts' -import ExportableCompositions from '@/store/exportableCompositions.ts' -import GalleryImages from '@/store/galleryImages.ts' -import Points from '@/store/current/points.ts' -import PointsMetrics from '@/store/current/pointsMetrics.ts' -import PointsSelection from '@/store/current/pointsSelection.ts' - -const backgroundImage = getModule(BackgroundImage) -const categories = getModule(Categories) -const exportableCompositions = getModule(ExportableCompositions) -const galleryImages = getModule(GalleryImages) -const points = getModule(Points) -const pointsMetrics = getModule(PointsMetrics) -const pointsSelection = getModule(PointsSelection) - -@Module({ dynamic: true, store, name: 'composition', namespaced: true }) -export default class Composition extends VuexModule { - @Action - saveComposition () { - // Save the current composition to exportableCompositions - const c = { - backgroundImage: backgroundImage.imageSrc, - points: points.asArray, - categories: categories.asArray - } - exportableCompositions.set(c) - } - - @Action - async fromExportableComposition (c: ExportableComposition) { - // Set the image - await backgroundImage.fromImageSrc(c.backgroundImage) - // TODO: validate the correspondance between point.categoryId and categories - points.fromArray(c.points) - categories.fromArray(c.categories) - pointsMetrics.clear() - pointsSelection.clear() - } - - @Action - async fromSrcOnly (src: string) { - // TODO: some checks on src? - await this.fromExportableComposition({ - backgroundImage: { src }, - points: [], - categories: categories.defaultArray - }) - } - - @Action - async fromSrc (src: string) { - // Nothing to do if the same image has been selected - if (backgroundImage.src !== src) { - this.saveComposition() - const c = exportableCompositions.get(src) - if (c !== undefined) { - await this.fromExportableComposition(c) - } else { - await this.fromSrcOnly(src) - } - } - } - - @Action - async initWithSomething () { - this.fromSrc(galleryImages.defaultSrc) - } -} diff --git a/src/store/current/points.ts b/src/store/current/points.ts deleted file mode 100644 index 0cf5654..0000000 --- a/src/store/current/points.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Current points (or agents) - -// See https://championswimmer.in/vuex-module-decorators/ -import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' -import uuid from 'uuid' -import { Point, XYCategory, XYId } from '@/utils/types.ts' - -export type Domain = [number, number] - -function random10To90 (): number { - return Math.random() * 80 + 10 -} - -const xDomain: Domain = [0, 100] -const yDomain: Domain = [0, 100] - -function clampToDomain (z: number, domain: Domain): number { - if (z < domain[0]) { - return domain[0] - } - if (z > domain[1]) { - return domain[1] - } - return z -} - -function clampPoint (point: Point): Point { - point.x = clampToDomain(+point.x, xDomain) - point.y = clampToDomain(+point.y, yDomain) - return point -} - -@Module({ dynamic: true, store, name: 'points', namespaced: true }) -export default class Points extends VuexModule { - // IMPORTANT. We use a hack to add Vue reactivity to Map and Set objects - // See https://stackoverflow.com/a/45441321/7351594 - - // State - state of truth - meant to be exported as a JSON - init definitions - list: Map = new Map() - listChangeTracker: number = 1 - nextNumber = 1 - - // Getters - cached, not meant to be exported - get asMap (): Map { - // By using `listChangeTracker` we tell Vue that this property depends on it, - // so it gets re-evaluated whenever `listChangeTracker` changes - HACK - return this.listChangeTracker ? this.list : this.list - } - get asArray (): Point[] { - return [...this.asMap.values()] - } - get size (): number { - return this.asMap.size - } - get get (): (id:string) => Point | undefined { - return (id:string): Point | undefined => this.asMap.get(id) - } - // USE? - // get keys (): IterableIterator { - // return this.asMap.keys() - // } - // get values (): IterableIterator { - // return this.asMap.values() - // } - // get has (): (id:string) => boolean { - // return (id:string): boolean => this.asMap.has(id) - // } - - // Mutations (synchronous) - @Mutation - setList (list: Map) { - this.list = list - // Trigger Vue updates - this.listChangeTracker += 1 - } - @Mutation - set (point: Point) { - // Don't allow positions outside of [0, 100] - this.list.set(point.id, clampPoint(point)) - this.listChangeTracker += 1 - } - @Mutation - delete (id: string) { - this.list.delete(id) - this.listChangeTracker += 1 - } - @Mutation - setNextNumber (n: number) { - this.nextNumber = n - } - // Actions - // Important: actions only receive 1 argument (payload). If you want to - // receive various arguments -> fields of an Object - @Action - fromMap (list: Map) { - this.setList(list) - this.resetNextNumber() - } - @Action - fromArray (list: Point[]) { - this.fromMap(new Map(list.map(p => [p.id, p]))) - } - @Action - clear () { - this.fromMap(new Map()) - } - @Action - incrementNextNumber () { - this.setNextNumber(this.nextNumber + 1) - } - @Action - resetNextNumber () { - // Set the next number to the max value among points + 1 - const m = this.asArray.reduce((a, c) => (c.number > a) ? c.number : a, 0) - this.setNextNumber(m + 1) - } - @Action - post (p: XYCategory) { - this.set({ id: uuid.v4(), ...p, number: this.nextNumber }) - this.incrementNextNumber() - } - @Action - deleteSet (ids: Set) { - const newList: Map = new Map(this.asMap) - for (const id of ids) { - newList.delete(id) - } - this.fromMap(newList) - } - @Action - setXY ({ id, x, y }: XYId) { - const point = this.asMap.get(id) - if (point === undefined) { - throw RangeError(`There is no point id=${id} in the list`) - } else { - this.set({ ...point, x, y }) - } - } - @Action - postCenter (categoryId: string) { - this.post({ x: 50, y: 50, categoryId }) - } - @Action - postRandom (categoryId: string) { - this.post({ x: random10To90(), y: random10To90(), categoryId }) - } -} diff --git a/src/store/exportableCompositions.ts b/src/store/exportableCompositions.ts deleted file mode 100644 index 51207bc..0000000 --- a/src/store/exportableCompositions.ts +++ /dev/null @@ -1,77 +0,0 @@ -// See https://championswimmer.in/vuex-module-decorators/ -import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' -import { ExportableComposition } from '@/utils/types.ts' - -@Module({ dynamic: true, store, name: 'exportableCompositions', namespaced: true }) -export default class ExportableCompositions extends VuexModule { - // State - state of truth - meant to be exported as a JSON - init definitions - // Currently: 1-to-1 correspondance between pictures and compositions - // TODO allow various compositions for a given picture? - - list: Map = new Map() - listChangeTracker: number = 1 - - // Getters - cached, not meant to be exported - get asMap (): Map { - // By using `listChangeTracker` we tell Vue that this property depends on it, - // so it gets re-evaluated whenever `listChangeTracker` changes - HACK - return this.listChangeTracker ? this.list : this.list - } - get asArray (): ExportableComposition[] { - return [...this.asMap.values()] - } - get size (): number { - return this.asMap.size - } - get get (): (id:string) => ExportableComposition | undefined { - return (id:string): ExportableComposition | undefined => this.asMap.get(id) - } - // USE? - // get keys (): IterableIterator { - // return this.asMap.keys() - // } - // get values (): IterableIterator { - // return this.asMap.values() - // } - // get has (): (id:string) => boolean { - // return (id:string): boolean => this.asMap.has(id) - // } - - // Mutations (synchronous) - @Mutation - fromMap (list: Map) { - this.list = list - // Trigger Vue updates - this.listChangeTracker += 1 - } - @Mutation - set (c: ExportableComposition) { - this.list.set(c.backgroundImage.src, c) - this.listChangeTracker += 1 - } - @Mutation - delete (id: string) { - this.list.delete(id) - this.listChangeTracker += 1 - } - // Actions - // Important: actions only receive 1 argument (payload). If you want to - // receive various arguments -> fields of an Object - @Action - fromArray (list: ExportableComposition[]) { - this.fromMap(new Map(list.map(c => [c.backgroundImage.src, c]))) - } - @Action - clear () { - this.fromMap(new Map()) - } - @Action - deleteSet (ids: Set) { - const newList: Map = new Map(this.asMap) - for (const id of ids) { - newList.delete(id) - } - this.fromMap(newList) - } -} diff --git a/src/store/galleryImages.ts b/src/store/galleryImages.ts deleted file mode 100644 index 3b9de0e..0000000 --- a/src/store/galleryImages.ts +++ /dev/null @@ -1,99 +0,0 @@ -// See https://championswimmer.in/vuex-module-decorators/ -import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' -import { ImageSrc } from '@/utils/types.ts' -import { imageSrcs } from '@/utils/severo_pictures.ts' -import { getImageUrl } from '@/utils/img.ts' - -const arrayToMap = (arr: ImageSrc[]): Map => { - return new Map(arr.map(s => [s.src, s])) -} -@Module({ dynamic: true, store, name: 'galleryImages', namespaced: true }) -export default class GalleryImages extends VuexModule { - // State - state of truth - meant to be exported as a JSON - init definitions - list: Map = arrayToMap(imageSrcs) - listChangeTracker: number = 1 - - // Getters - cached, not meant to be exported - get asMap (): Map { - // By using `listChangeTracker` we tell Vue that this property depends on it, - // so it gets re-evaluated whenever `listChangeTracker` changes - HACK - return this.listChangeTracker ? this.list : this.list - } - get asArray (): ImageSrc[] { - return [...this.asMap.values()] - } - get size (): number { - return this.asMap.size - } - get get (): (id:string) => ImageSrc | undefined { - return (id:string): ImageSrc | undefined => this.asMap.get(id) - } - get defaultSrc (): string { - return this.size ? this.asArray[0].src : '' - } - // USE? - // get keys (): IterableIterator { - // return this.asMap.keys() - // } - // get values (): IterableIterator { - // return this.asMap.values() - // } - // get has (): (id:string) => boolean { - // return (id:string): boolean => this.asMap.has(id) - // } - - // Mutations (synchronous) - @Mutation - fromMap (list: Map) { - this.list = list - // Trigger Vue updates - this.listChangeTracker += 1 - } - @Mutation - set (s: ImageSrc) { - this.list.set(s.src, s) - this.listChangeTracker += 1 - } - @Mutation - delete (id: string) { - this.list.delete(id) - this.listChangeTracker += 1 - } - // Actions - // Important: actions only receive 1 argument (payload). If you want to - // receive various arguments -> fields of an Object - @Action - fromArray (list: ImageSrc[]) { - this.fromMap(arrayToMap(list)) - } - @Action - appendArray (list: ImageSrc[]) { - for (const s of list) { - this.set(s) - } - } - @Action - async appendFilesArray (files: File[]) { - const list: ImageSrc[] = [] - for (const f of files) { - const base64Str = await getImageUrl(f) - if (base64Str !== '') { - list.push({ src: base64Str }) - } - } - this.appendArray(list) - } - @Action - clear () { - this.fromMap(new Map()) - } - @Action - deleteSet (ids: Set) { - const newList: Map = new Map(this.asMap) - for (const id of ids) { - newList.delete(id) - } - this.fromMap(newList) - } -} diff --git a/src/store/index.ts b/src/store/index.ts index acf9a47..76ea3f2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,10 +1,41 @@ import Vue from 'vue' -import Vuex from 'vuex' +import Vuex, { Store } from 'vuex' +import createPersistedState from 'vuex-persistedstate' +import { sync } from 'vuex-router-sync' +import router from '@/router' + +// Note: you shouldn't need to import store modules here. +// See https://github.com/garyo/vuex-module-decorators-example/ +import { initializeStores, modules } from '@/store/store-accessor' Vue.use(Vuex) -const store = new Vuex.Store({ +// Initialize the modules using a Vuex plugin that runs when the root store is +// first initialized. This is vital to using static modules because the +// modules don't know the root store when they are loaded. Initializing them +// when the root store is created allows them to be hooked up properly. +const initializer = (store: Store) => { + // done. Returns an unsync callback fn + sync(store, router) + return initializeStores(store) +} +export const plugins = [initializer] +export * from '@/store/store-accessor' // re-export the modules +// Note that it can be accessed in the localStorage under the key "settings", eg: +// {"settings":{"me":"uresE","showImageColors":true}} +const persistedState = createPersistedState({ + key: 'settings', + paths: ['settings.userName', 'settings.showImageColors'] }) -export default store +// Export the root store. You can add mutations & actions here as well. +// Note that this is a standard Vuex store, not a vuex-module-decorator one. +// (Perhaps could be, but I put everything in modules) +export default new Store({ + plugins: [initializer, persistedState], + modules, + state: { root: 'I am groot' }, + mutations: {}, + actions: {} +}) diff --git a/src/store/current/backgroundImage.ts b/src/store/modules/backgroundImage.ts similarity index 51% rename from src/store/current/backgroundImage.ts rename to src/store/modules/backgroundImage.ts index b597d8f..02e62c1 100644 --- a/src/store/current/backgroundImage.ts +++ b/src/store/modules/backgroundImage.ts @@ -2,17 +2,14 @@ // See https://championswimmer.in/vuex-module-decorators/ import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import { ImageSrc } from '@/utils/types.ts' import { fetchImage } from '@/utils/img.ts' +import { ImageSpec } from '@/types' -import store from '@/store' - -@Module({ dynamic: true, store, name: 'backgroundImage', namespaced: true }) -export default class BackgroundImage extends VuexModule { +@Module({ name: 'backgroundImage' }) +export default class BackgroundImageModule extends VuexModule { // State - state of truth - meant to be exported as a JSON - init definitions image: HTMLImageElement = new Image() isReady: boolean = false - // TODO: add thumbnailSrc? // Getters - cached, not meant to be exported get naturalWidth (): number { @@ -21,19 +18,6 @@ export default class BackgroundImage extends VuexModule { get naturalHeight (): number { return this.image.naturalHeight } - get src (): string { - return this.image.src - } - get srcset (): string { - return this.image.srcset - } - get imageSrc (): ImageSrc { - return { - src: this.image.src, - srcset: this.image.srcset - } - } - get aspectRatio (): number { if (this.naturalHeight === 0) { return 1 @@ -42,7 +26,7 @@ export default class BackgroundImage extends VuexModule { } @Mutation - fromHTMLImageElement (image: HTMLImageElement) { + setImage (image: HTMLImageElement) { this.image = image } @Mutation @@ -55,9 +39,23 @@ export default class BackgroundImage extends VuexModule { } @Action - async fromImageSrc (imageSrc: ImageSrc) { - this.setNotReady() - this.fromHTMLImageElement(await fetchImage(imageSrc)) - this.setReady() + async update (imageSpec: ImageSpec) { + // TODO: maybe show the placeholder when updating the image + // But it's surely better to just not + // change anything, ie. let the previous image, until the new one is ready + // this.setNotReady() + if ( + imageSpec.src !== this.image.src || + imageSpec.srcset !== this.image.srcset || + !this.isReady + ) { + try { + // if the background image has changed, try to update it + this.setImage(await fetchImage(imageSpec)) + this.setReady() + } catch (e) { + throw new ReferenceError('Background image could not be loaded') + } + } } } diff --git a/src/store/modules/categories.ts b/src/store/modules/categories.ts new file mode 100644 index 0000000..cb90fb8 --- /dev/null +++ b/src/store/modules/categories.ts @@ -0,0 +1,32 @@ +// Current categories + +// See https://championswimmer.in/vuex-module-decorators/ +import { Module, VuexModule } from 'vuex-module-decorators' +import { Category } from '@/types' + +import { compositionsStore } from '@/store/store-accessor' + +@Module({ name: 'categories', namespaced: true }) +export default class CategoriesModule extends VuexModule { + // State - state of truth - meant to be exported as a JSON - init definitions + + // Getters - cached, not meant to be exported + get asArray (): Category[] { + return Object.values(compositionsStore.current.categories) + } + get asMap (): Map { + return new Map(this.asArray.map(c => [c.id, c])) + } + get size (): number { + return this.asMap.size + } + get getColor (): (id: string) => string { + return (id: string): string => { + const category = this.asMap.get(id) + if (category === undefined) { + throw new RangeError(`No category found with id=${id}`) + } + return category.color + } + } +} diff --git a/src/store/modules/compositions.ts b/src/store/modules/compositions.ts new file mode 100644 index 0000000..85ad279 --- /dev/null +++ b/src/store/modules/compositions.ts @@ -0,0 +1,201 @@ +// See https://championswimmer.in/vuex-module-decorators/ +import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' +import uuid from 'uuid' +import Automerge from 'automerge' +import { + AutomergeState, + Composition, + Category, + FakeMap, + ImageSpec, + Point, + XYId +} from '@/types' +import { + placeholderImageSpec, + defaultImageSpecs, + defaultColors +} from '@/utils/defaults.ts' +import { + goToComposition +} from '@/utils/router.ts' +import { + backgroundImageStore, + pointsMetricsStore, + pointsSelectionStore, + socketStore +} from '@/store/store-accessor' + +const createCategories = (): { [id: string]: Category } => { + return defaultColors.reduce((a: { [id: string]: Category }, c: string) => { + const id: string = uuid.v4() + a[id] = { id, color: c } + return a + }, {}) +} + +const imageSpecToComposition = (imageSpec: ImageSpec): Composition => { + return { + id: uuid.v4(), + backgroundImage: imageSpec, + categories: createCategories(), + points: {} + } +} + +const getRandomElement = (arr: any[]): string => { + return arr[Math.floor(Math.random() * arr.length)] +} + +const initFromDefaults = (imageSpecs: ImageSpec[]): AutomergeState => { + return Automerge.change( + Automerge.init(), + doc => { + doc.compositions = {} + for (const imageSpec of imageSpecs) { + const c: Composition = imageSpecToComposition(imageSpec) + doc.compositions[c.id] = c + } + + // slice(1): to remove the first image (placeholder) + const ids = Object.keys(doc.compositions).slice(1) + doc.currentId = getRandomElement(ids) + } + ) +} + +const defaultState = initFromDefaults([ + placeholderImageSpec, + ...defaultImageSpecs +]) + +@Module({ name: 'compositions', namespaced: true }) +export default class CompositionsModule extends VuexModule { + // State - state of truth - meant to be exported as a JSON - init definitions + automergeState: AutomergeState = defaultState + + // Getters - cached, not meant to be exported + get compositions (): FakeMap { + return this.automergeState.compositions + } + get currentId (): string { + return this.automergeState.currentId + } + get asArray (): Composition[] { + return Object.values(this.compositions) + } + get get (): (id: string) => Composition | undefined { + return (id: string): Composition | undefined => this.compositions[id] + } + get current (): Composition { + const c: Composition | undefined = this.get(this.currentId) + if (c === undefined) { + throw new RangeError( + 'No current composition has been found. The compositions store should always provide a current composition' + ) + } + return c + } + get has (): (id: string) => boolean { + return (id: string): boolean => this.compositions[id] !== undefined + } + + // Mutations (synchronous) + @Mutation + setAutomergeState (automergeState: AutomergeState) { + this.automergeState = automergeState + } + // Actions + // Important: actions only receive 1 argument (payload). If you want to + // receive various arguments -> fields of an Object + @Action + async resetAutomergeState (automergeState: AutomergeState) { + const newComposition = automergeState.compositions[automergeState.currentId] + + // Update the background image (if required) + // an exception will throw if the image cannot be loaded + await backgroundImageStore.update(newComposition.backgroundImage) + + // Clear the temporary data + if (newComposition.id !== this.currentId) { + pointsSelectionStore.clear() + } + // Always? Maybe it's quicker to test if the points have been modified + pointsMetricsStore.clear() + + this.setAutomergeState(automergeState) + + // Update the URL + goToComposition(this.current) + } + @Action + async applyAutomergeChanges (changes: Automerge.Change[]) { + const newState = Automerge.applyChanges(this.automergeState, changes) + await this.resetAutomergeState(newState) + } + @Action + appendCompositionFromImageSpec (imageSpec: ImageSpec) { + const c: Composition = imageSpecToComposition(imageSpec) + + // Register the change in automerge + const newState = Automerge.change(this.automergeState, doc => { + doc.compositions[c.id] = c + }) + this.setAutomergeState(newState) + } + @Action updateAutomergeState (newState: AutomergeState) { + try { + const changes = Automerge.getChanges(this.automergeState, newState) + socketStore.emitUpdateState(changes) + } catch (e) { + throw new Error('The new state could not be set') + } + this.setAutomergeState(newState) + + // Update the URL + goToComposition(this.current) + } + @Action({ rawError: true }) + async updateCurrentComposition (newComposition: Composition) { + // Update the background image + // an exception will throw if the image cannot be loaded + await backgroundImageStore.update(newComposition.backgroundImage) + + // Clear the temporary data + if (newComposition.id !== this.currentId) { + pointsSelectionStore.clear() + } + pointsMetricsStore.clear() + + // Register the change in automerge + const newState = Automerge.change(this.automergeState, doc => { + doc.compositions[newComposition.id] = newComposition + doc.currentId = newComposition.id + }) + this.updateAutomergeState(newState) + } + @Action + deletePoints (ids: Set) { + const newState = Automerge.change(this.automergeState, doc => { + for (const id of ids.values()) { + delete doc.compositions[this.currentId].points[id] + } + }) + this.updateAutomergeState(newState) + } + @Action + movePoint ({ id, x, y }: XYId) { + const newState = Automerge.change(this.automergeState, doc => { + doc.compositions[this.currentId].points[id].x = x + doc.compositions[this.currentId].points[id].y = y + }) + this.updateAutomergeState(newState) + } + @Action + appendPoint (point: Point) { + const newState = Automerge.change(this.automergeState, doc => { + doc.compositions[this.currentId].points[point.id] = point + }) + this.updateAutomergeState(newState) + } +} diff --git a/src/store/modules/points.ts b/src/store/modules/points.ts new file mode 100644 index 0000000..7edeb54 --- /dev/null +++ b/src/store/modules/points.ts @@ -0,0 +1,101 @@ +// Current points (or agents) + +// See https://championswimmer.in/vuex-module-decorators/ +import { Action, Module, VuexModule } from 'vuex-module-decorators' +import uuid from 'uuid' +import { Point, XY, XYId } from '@/types' + +import { compositionsStore } from '@/store/store-accessor' + +export type Domain = [number, number] + +function random10To90 (): number { + return Math.random() * 80 + 10 +} + +// If the domain changes, don't forget to also change the value of MAX_DIGITS +const xDomain: Domain = [0, 100] +const yDomain: Domain = [0, 100] +const MAX_DIGITS = 2 + +function clip (z: number, domain: Domain): number { + if (z < domain[0]) { + return domain[0] + } + if (z > domain[1]) { + return domain[1] + } + return z +} + +const MULTIPLIER = Math.pow(10, MAX_DIGITS) +function restrictPrecision (x: number): number { + return Math.round(x * MULTIPLIER) / MULTIPLIER +} + +function clipAndRound ({ x, y }: XY): XY { + return { + x: clip(restrictPrecision(+x), xDomain), + y: clip(restrictPrecision(+y), yDomain) + } +} + +@Module({ name: 'points', namespaced: true }) +export default class PointsModule extends VuexModule { + // State - state of truth - meant to be exported as a JSON - init definitions + + // Getters - cached, not meant to be exported + get asArray (): Point[] { + return Object.values(compositionsStore.current.points).sort( + (p1, p2) => p1.number - p2.number + ) + } + get asMap (): Map { + return new Map(this.asArray.map(p => [p.id, p])) + } + get size (): number { + return this.asMap.size + } + get nextNumber (): number { + // TODO: improve this, because surely there will be collisions when collaborating + // the first free integer (there might be gaps between points numbers) + // the points are ordered by number + const startAt = 1 + if (this.size === 0 || this.asArray[0].number > startAt) { + return startAt + } + const pointBeforeGap = + this.asArray + .slice(0, this.size - 1) + .find((p, i) => p.number + 1 < this.asArray[i + 1].number) || + this.asArray[this.size - 1] + return pointBeforeGap.number + 1 + } + + // Actions + // Important: actions only receive 1 argument (payload). If you want to + // receive various arguments -> fields of an Object + @Action + deleteSet (ids: Set) { + compositionsStore.deletePoints(ids) + } + @Action + setXY ({ id, x, y }: XYId) { + const point = this.asMap.get(id) + if (point === undefined) { + throw RangeError(`There is no point id=${id} in the list`) + } else { + compositionsStore.movePoint({ id, ...clipAndRound({ x, y }) }) + } + } + @Action + postRandom (categoryId: string) { + const point = { + id: uuid.v4(), + number: this.nextNumber, + categoryId, + ...clipAndRound({ x: random10To90(), y: random10To90() }) + } + compositionsStore.appendPoint(point) + } +} diff --git a/src/store/current/pointsMetrics.ts b/src/store/modules/pointsMetrics.ts similarity index 55% rename from src/store/current/pointsMetrics.ts rename to src/store/modules/pointsMetrics.ts index db4ec30..e18f478 100644 --- a/src/store/current/pointsMetrics.ts +++ b/src/store/modules/pointsMetrics.ts @@ -2,15 +2,14 @@ // See https://championswimmer.in/vuex-module-decorators/ import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' export interface Area { - pointId: string; - area: number; + pointId: string + area: number } -@Module({ dynamic: true, store, name: 'pointsMetrics', namespaced: true }) -export default class PointsMetrics extends VuexModule { +@Module({ name: 'pointsMetrics', namespaced: true }) +export default class PointsMetricsModule extends VuexModule { // IMPORTANT. We use a hack to add Vue reactivity to Map and Set objects // See https://stackoverflow.com/a/45441321/7351594 @@ -24,22 +23,16 @@ export default class PointsMetrics extends VuexModule { // so it gets re-evaluated whenever `listChangeTracker` changes - HACK return this.areasChangeTracker ? this.areas : this.areas } - get areasAsArray (): Area[] { - return [...this.areasAsMap.values()] - } get size (): number { return this.areasAsMap.size } - get getArea (): (id:string) => Area | undefined { - return (id:string): Area | undefined => this.areasAsMap.get(id) + get getArea (): (id: string) => Area | undefined { + return (id: string): Area | undefined => this.areasAsMap.get(id) } - // get has (): (id:string) => boolean { - // return (id:string): boolean => this.asMap.has(id) - // } // Mutations (synchronous) @Mutation - areasFromMap (areas: Map) { + setAreas (areas: Map) { this.areas = areas // Trigger Vue updates this.areasChangeTracker += 1 @@ -49,28 +42,11 @@ export default class PointsMetrics extends VuexModule { this.areas.set(area.pointId, area) this.areasChangeTracker += 1 } - @Mutation - deleteArea (pointId: string) { - this.areas.delete(pointId) - this.areasChangeTracker += 1 - } // Actions // Important: actions only receive 1 argument (payload). If you want to // receive various arguments -> fields of an Object @Action - areasFromArray (areas: Area[]) { - this.areasFromMap(new Map(areas.map(p => [p.pointId, p]))) - } - @Action clear () { - this.areasFromMap(new Map()) - } - @Action - deleteSet (ids: Set) { - const newList: Map = new Map(this.areasAsMap) - for (const id of ids) { - newList.delete(id) - } - this.areasFromMap(newList) + this.setAreas(new Map()) } } diff --git a/src/store/current/pointsSelection.ts b/src/store/modules/pointsSelection.ts similarity index 71% rename from src/store/current/pointsSelection.ts rename to src/store/modules/pointsSelection.ts index 4365df5..814d281 100644 --- a/src/store/current/pointsSelection.ts +++ b/src/store/modules/pointsSelection.ts @@ -2,10 +2,9 @@ // See https://championswimmer.in/vuex-module-decorators/ import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' -@Module({ dynamic: true, store, name: 'pointsSelection', namespaced: true }) -export default class PointsSelection extends VuexModule { +@Module({ name: 'pointsSelection', namespaced: true }) +export default class PointsSelectionModule extends VuexModule { // IMPORTANT. We use a hack to add Vue reactivity to Map and Set objects // See https://stackoverflow.com/a/45441321/7351594 @@ -25,20 +24,13 @@ export default class PointsSelection extends VuexModule { get size (): number { return this.asSet.size } - get has (): (id:string) => boolean { - return (id:string): boolean => this.asSet.has(id) + get has (): (id: string) => boolean { + return (id: string): boolean => this.asSet.has(id) } - // USE? - // get keys (): IterableIterator { - // return this.asSet.keys() - // } - // get values (): IterableIterator { - // return this.asSet.values() - // } // Mutations (synchronous) @Mutation - fromSet (ids: Set) { + set (ids: Set) { this.ids = ids // Trigger Vue updates this.idsChangeTracker += 1 @@ -56,11 +48,11 @@ export default class PointsSelection extends VuexModule { // Actions @Action fromArray (ids: string[]) { - this.fromSet(new Set(ids)) + this.set(new Set(ids)) } - @Action + @Action({ rawError: true }) clear () { - this.fromSet(new Set()) + this.set(new Set()) } @Action toggle (id: string) { diff --git a/src/store/modules/settings.ts b/src/store/modules/settings.ts new file mode 100644 index 0000000..5da5d3d --- /dev/null +++ b/src/store/modules/settings.ts @@ -0,0 +1,36 @@ +// See https://championswimmer.in/vuex-module-decorators/ +import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' + +@Module({ name: 'settings', namespaced: true }) +export default class SettingsModule extends VuexModule { + // State - state of truth - meant to be exported as a JSON - init definitions + + // TODO: add color too + userName: string = 'noname' + showImageColors: boolean = true + isCollaborationActive: boolean = false + + // Mutations (synchronous) + @Mutation + setShowImageColors (value: boolean) { + this.showImageColors = value + } + @Mutation + setIsCollaborationActive (value: boolean) { + this.isCollaborationActive = value + } + @Mutation + setUserName (userName: string) { + this.userName = userName + } + + // Actions (asynchronous) + @Action + enableCollaboration () { + this.setIsCollaborationActive(true) + } + @Action + disableCollaboration () { + this.setIsCollaborationActive(false) + } +} diff --git a/src/store/modules/socket.ts b/src/store/modules/socket.ts new file mode 100644 index 0000000..f23e256 --- /dev/null +++ b/src/store/modules/socket.ts @@ -0,0 +1,231 @@ +// See https://championswimuserNamer.in/vuex-module-decorators/ +import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators' +import ioClient from 'socket.io-client' +import Automerge from 'automerge' +import { AutomergeState, Guest } from '@/types' + +import { compositionsStore, settingsStore } from '@/store/store-accessor' + +// TypeScript definitions copied from socket-server +interface ExportedUser { + id: string + name: string + color: string +} +interface UpdateUserNameAckArgs { + updated: boolean + error?: Exception +} +interface UpdateStateAckArgs { + updated: boolean + error?: Exception +} +interface SendStateAck { + ($data: SendStateAckArgs): void +} +interface SendStateAckArgs { + sent: boolean + state: string + error?: Exception +} +interface Exception { + name: string + message: string +} + +const getGuestFromUsers = ( + id: SocketIOClient.Socket['id'], + users: ExportedUser[] +): Guest => { + const user: ExportedUser | undefined = users.find(user => user.id === id) + if (user === undefined) { + throw new ReferenceError('Socket id not found in the users list') + } + return userToGuest(user) +} + +const userToGuest = (user: ExportedUser) => { + // TODO: validation + return { sId: user.id, name: user.name, color: user.color } +} + +const options: SocketIOClient.ConnectOpts = { + autoConnect: false +} + +// By default: use a local server (https://github.com/LyonDataViz/socket-server/) +// in a development environment, else a remote server +// TODO: make it easier to switch the server +const serverUrl = + process.env.NODE_ENV === 'development' + ? 'http://localhost:3000/occupapp-beta' + : 'https://immense-coast-15741.herokuapp.com/occupapp-beta' + +@Module({ name: 'socket', namespaced: true }) +export default class SocketModule extends VuexModule { + // State - state of truth - meant to be exported as a JSON - init definitions + + // selfLocked is used to lock the user name and color until the server has + // sent its first list of users. + // TODO: replace this "hack" by using automerge in socket-server for user name + // and user color... + // OR: initialized (or any other state management) - to give an indication to + // the UI that the data should not be displayed because the connection process + // has not finished yet + selfLocked: boolean = true + + socket: SocketIOClient.Socket = ioClient(serverUrl, options) + + guest: Guest = {} + otherGuests: Guest[] = [] + + get defaultGuest (): Guest { + // TODO: simplify the state mess (with automerge?). Currently, name being + // empty means the app will wait for the server to send a default name. + + return settingsStore.userName !== '' ? { name: settingsStore.userName } : {} + } + + @Mutation + setSelfLocked (selfLocked: boolean) { + this.selfLocked = selfLocked + } + @Mutation + setGuest (guest: Guest) { + this.guest = guest + } + @Mutation + setOtherGuests (guests: Guest[]) { + this.otherGuests = guests + } + + @Action + connect () { + // Events + // - from server + // - 'reset-state' + // - 'update-state' + // - 'users-list' + // - to server + // - 'update-state' + // - 'update-user-name' + // - 'update-user-color' - TODO + + // First, update the user name + this.setGuest(this.defaultGuest) + + this.socket.connect() + + this.socket.on('users-list', (users: ExportedUser[]) => { + // TODO: validation + this.setOtherGuestsFromUsers(users) + + if (this.selfLocked === true) { + // release the lock + this.setSelfLocked(false) + // update user name and color if they did not exist. + // Else: send the new values + // TODO: simplify this mess (maybe when the rooms will be implemented, + // and the user will have to specifically join a room, giving their + // name) + const guest = getGuestFromUsers(this.socket.id, users) + if (this.guest.name !== undefined && this.guest.name !== '') { + // Send local name to server + guest.name = this.guest.name + this.emitUpdateUserName(this.guest.name) + } + this.setGuest(guest) + if (guest.name !== settingsStore.userName) { + settingsStore.setUserName(name) + } + } + }) + this.socket.on('send-state', (ack: SendStateAck) => { + // The server asks the client to setup the shared state + ack({ + sent: true, + state: Automerge.save(compositionsStore.automergeState) + }) + }) + this.socket.on('reset-state', (encodedState: string) => { + // State sent by the server - try to blindly set this new state + // TODO: validate better + const state: AutomergeState = Automerge.load(encodedState) + try { + compositionsStore.resetAutomergeState(state) + } catch (e) { + throw new Error( + 'The state received from the socket.io server could not be loaded...' + ) + } + }) + this.socket.on('update-state', (changes: Automerge.Change[]) => { + // State changes sent by the server - try to blindly apply them + // TODO: validate + try { + compositionsStore.applyAutomergeChanges(changes) + } catch (e) { + throw new Error( + 'The state changes received from the socket.io server could not be applied...' + ) + } + }) + } + @Action + setOtherGuestsFromUsers (users: ExportedUser[]) { + // TODO: validation + this.setOtherGuests( + users + .map(user => userToGuest(user)) + .filter(guest => guest.sId !== this.socket.id) + ) + } + @Action + disconnect () { + this.socket.disconnect() + this.setGuest(this.defaultGuest) + this.setOtherGuests([]) + this.setSelfLocked(true) + // TODO: go back to some previous composition? + } + @Action + updateGuestName (name: string) { + // TODO: validate + this.setGuest({ ...this.guest, name }) + if (name !== settingsStore.userName) { + settingsStore.setUserName(name) + } + this.emitUpdateUserName(name) + } + @Action + emitUpdateUserName (name: string) { + // TODO: validate to avoid sending an empty string + this.socket.emit( + 'update-user-name', + { name }, + (response: UpdateUserNameAckArgs) => { + // TODO: validate + if (response.updated !== true) { + // TODO: improve error message + throw new Error(response.error ? response.error.message : undefined) + // TODO: log error (to the console or to a service) + } + } + ) + } + @Action + emitUpdateState (changes: Automerge.Change[]) { + this.socket.emit( + 'update-state', + changes, + (response: UpdateStateAckArgs) => { + // TODO: validate + if (response.updated !== true) { + // TODO: improve error message + throw new Error(response.error ? response.error.message : undefined) + // TODO: log error (to the console or to a service) + } + } + ) + } +} diff --git a/src/store/settings.ts b/src/store/settings.ts deleted file mode 100644 index 48dc590..0000000 --- a/src/store/settings.ts +++ /dev/null @@ -1,16 +0,0 @@ -// See https://championswimmer.in/vuex-module-decorators/ -import { Module, Mutation, VuexModule } from 'vuex-module-decorators' -import store from '@/store' - -@Module({ dynamic: true, store, name: 'settings', namespaced: true }) -export default class Settings extends VuexModule { - // State - state of truth - meant to be exported as a JSON - init definitions - - showImageColors: boolean = true - - // Mutations (synchronous) - @Mutation - setShowImageColors (value: boolean) { - this.showImageColors = value - } -} diff --git a/src/store/store-accessor.ts b/src/store/store-accessor.ts new file mode 100644 index 0000000..7a85f7b --- /dev/null +++ b/src/store/store-accessor.ts @@ -0,0 +1,54 @@ +// This is the "store accessor": +// It initializes all the modules using a Vuex plugin (see store/index.ts) +// In here you import all your modules, call getModule on them to turn them +// into the actual stores, and then re-export them. + +import { Store } from 'vuex' +import { getModule } from 'vuex-module-decorators' + +import BackgroundImageModule from '@/store/modules/backgroundImage' +import CategoriesModule from '@/store/modules/categories' +import CompositionsModule from '@/store/modules/compositions' +import PointsModule from '@/store/modules/points' +import PointsMetricsModule from '@/store/modules/pointsMetrics' +import PointsSelectionModule from '@/store/modules/pointsSelection' +import SettingsModule from '@/store/modules/settings' +import SocketModule from '@/store/modules/socket' + +// Each store is the singleton instance of its module class +// Use these -- they have methods for state/getters/mutations/actions +// (result from getModule(...)) +export let backgroundImageStore: BackgroundImageModule +export let categoriesStore: CategoriesModule +export let compositionsStore: CompositionsModule +export let pointsStore: PointsModule +export let pointsMetricsStore: PointsMetricsModule +export let pointsSelectionStore: PointsSelectionModule +export let socketStore: SocketModule +export let settingsStore: SettingsModule + +// initializer plugin: sets up state/getters/mutations/actions for each store +export function initializeStores (store: Store): void { + backgroundImageStore = getModule(BackgroundImageModule, store) + categoriesStore = getModule(CategoriesModule, store) + compositionsStore = getModule(CompositionsModule, store) + pointsStore = getModule(PointsModule, store) + pointsMetricsStore = getModule(PointsMetricsModule, store) + pointsSelectionStore = getModule(PointsSelectionModule, store) + settingsStore = getModule(SettingsModule, store) + socketStore = getModule(SocketModule, store) +} + +// for use in 'modules' store init (see store/index.ts), so each module +// appears as an element of the root store's state. +// (This is required!) +export const modules = { + 'backgroundImage': BackgroundImageModule, + 'categories': CategoriesModule, + 'compositions': CompositionsModule, + 'points': PointsModule, + 'pointsMetrics': PointsMetricsModule, + 'pointsSelection': PointsSelectionModule, + 'settings': SettingsModule, + 'socket': SocketModule +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..aa04dfc --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,74 @@ +import { Route } from 'vue-router/types/router' + +export type FakeMap = {[id: string]: T} + +// See https://stackoverflow.com/a/51114250/7351594 +// See https://github.com/automerge/automerge/blob/master/@types/automerge/index.d.ts +export type AutomergeState = import('automerge').Doc<{ + compositions: FakeMap, + currentId: string +}> + +// Images +export interface ImageSpec { + src: string + exportableSrc: string + srcset?: string + thumbnailSrc?: string +} + +// Points +export interface XY { + x: number + y: number +} +export interface XYId extends XY { + id: string +} +export interface Point extends XYId { + number: number + categoryId: string +} + +// Categories +export interface Category { + id: string + color: string +} + +// Compositions +export interface Composition { + id: string + backgroundImage: ImageSpec + points: FakeMap + categories: FakeMap +} + +// URL Query Specification (the data required to forge an URL query) +export interface UrlQuerySpec { + id: string + img: UrlQuerySpecImg + pts?: UrlQuerySpecPt[] + cats?: UrlQuerySpecCat[] +} +export type UrlQuerySpecImg = string +export interface UrlQuerySpecPt { + id: string + n: number + x: number + y: number + c: string +} +export interface UrlQuerySpecCat { + id: string + c: string +} + +export type UrlQuery = Route['query'] + +// Collaboration +export interface Guest { + sId?: SocketIOClient.Socket['id'] + name?: string + color?: string +} diff --git a/src/utils/defaults.ts b/src/utils/defaults.ts new file mode 100644 index 0000000..08c2540 --- /dev/null +++ b/src/utils/defaults.ts @@ -0,0 +1,51 @@ +import * as d3 from 'd3' +import { ImageSpec } from '@/types' +import { getPlaceholderSrc } from './img' + +// Images +function forgeUrl (name: string, width: number): string { + return `https://github.com/severo/pictures/raw/master/images,w_${width}/${name}.jpg` +} + +const names: string[] = [ + 'petanque', + 'boats', + 'honeycomb', + 'spider', + 'wolves', + 'bazzania', + 'basketball', + 'beach', + 'cuzco' +] +const widths: number[] = [320, 640, 768, 1024, 1366, 1600, 1920] +const sortedWidth: number[] = [...widths].sort( + (a: number, b: number): number => a - b +) +const minWidth: number = sortedWidth[0] +const maxWidth: number = sortedWidth[sortedWidth.length - 1] + +const defaultImageSpecs: ImageSpec[] = names.map(name => { + return { + src: forgeUrl(name, maxWidth), + srcset: widths.map(w => `${forgeUrl(name, w)} ${w}w`).join(','), + exportableSrc: forgeUrl(name, maxWidth), + thumbnailSrc: forgeUrl(name, minWidth) + } +}) +const placeholderImageSpec = { + src: getPlaceholderSrc(), + srcset: '', + thumbnailSrc: '', + exportableSrc: getPlaceholderSrc(50, 25) +} + +// Categories +const defaultColors: string[] = [ + d3.rgb(255, 195, 8), + d3.rgb(172, 159, 253), + d3.rgb(181, 246, 66), + d3.rgb(239, 106, 222) +].map(c => c.hex()) + +export { placeholderImageSpec, defaultImageSpecs, defaultColors } diff --git a/src/utils/img.ts b/src/utils/img.ts index 61c09dd..0dcf2f1 100644 --- a/src/utils/img.ts +++ b/src/utils/img.ts @@ -1,11 +1,11 @@ -import { ImageSrc } from '@/utils/types.ts' +import { ImageSpec } from '@/types' -// TODO fix a small height and width initially to avoid loading the heaviest image, if srcset exists (responsive image)? is this how onload works? +// TODO set a small height and width initially to avoid loading the heaviest image, if srcset exists (responsive image)? is this how onload works? // TODO should we also generate a thumbnail? export async function fetchImage ({ src, srcset -}: ImageSrc): Promise { +}: ImageSpec): Promise { return new Promise((resolve, reject) => { const img: HTMLImageElement = new Image() img.onload = () => resolve(img) @@ -17,6 +17,78 @@ export async function fetchImage ({ }) } +export const getPlaceholderSrc = ( + w: number = 300, + h: number = 150, + color: string = '#ccc' +): string => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + canvas.width = w + canvas.height = h + if (context) { + context.fillStyle = color + context.fillRect(0, 0, w, h) + } + + return canvas.toDataURL('image/jpeg') +} + +export const getExportableSrc = async ( + src: string, + w?: number, + h?: number +): Promise => { + return new Promise((resolve, reject) => { + const img: HTMLImageElement = new Image() + img.onload = () => { + resolve(coverStr(img, w, h)) + } + img.onerror = reject + img.src = src + }) +} + +export const coverStr = ( + image: HTMLImageElement, + w: number = 50, + h?: number +): string => { + const sNaturalWidth = image.naturalWidth + const sNaturalHeight = image.naturalHeight + const sAspectRatio = sNaturalWidth / sNaturalHeight + + const dx = 0 + const dy = 0 + const dWidth = w + const dHeight = h !== undefined ? h : w / sAspectRatio + + const dAspectRatio = dWidth / dHeight + + let sx, sy, sWidth, sHeight + if (sAspectRatio > dAspectRatio) { + sHeight = sNaturalHeight + sWidth = sNaturalHeight * dAspectRatio + sx = (sNaturalWidth - sWidth) / 2 + sy = 0 + } else { + sHeight = sNaturalWidth / dAspectRatio + sWidth = sNaturalWidth + sx = 0 + sy = (sNaturalHeight - sHeight) / 2 + } + + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + canvas.width = dWidth + canvas.height = dHeight + if (context) { + context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) + } + + return canvas.toDataURL('image/jpeg') +} + /* * Create a Base64 Image URL, with rotation applied to compensate for EXIF orientation, if needed. * @@ -30,11 +102,10 @@ export async function fetchImage ({ export async function getImageUrl ( file: File, maxWidth: number | undefined = 999999 -) { +): Promise { return readOrientation(file).then(orientation => { return applyRotation(file, orientation || 1, maxWidth) - } - ) + }) } /** diff --git a/src/utils/parse.ts b/src/utils/parse.ts new file mode 100644 index 0000000..f81e1ba --- /dev/null +++ b/src/utils/parse.ts @@ -0,0 +1,116 @@ +// Methods to manage the URL query string +import { Category, Composition, FakeMap, Point, UrlQuery } from '@/types' + +const parseId = (query: UrlQuery): string | undefined => { + // First: ensure there is an img parameter + if (query.id !== undefined && typeof query.id === 'string') { + return query.id + } +} +// Check the img parameter in the query +// It can be: +// - an image src, eg: `https://github.com/severo/pictures/raw/master/images,w_1920/petanque.jpg` +// - a base64 data URL, eg: `data:image/jpeg;base64,/9j/4AAQSkZJR...` +const parseImageSpec = (query: UrlQuery): string | undefined => { + // First: ensure there is an img parameter + if (query.img !== undefined && typeof query.img === 'string') { + return query.img + } +} + +const parseCategories = (query: UrlQuery): FakeMap | undefined => { + // First: ensure there is a cats field + if (query.cats !== undefined && typeof query.cats === 'string') { + const cats = JSON.parse(query.cats) + const catsMap: FakeMap = {} + for (const c of cats) { + // TODO: add more validation (uuid length? color formats?) + if ( + c.id !== undefined && + typeof c.id === 'string' && + c.c !== undefined && + typeof c.c === 'string' + ) { + catsMap[c.id] = { id: c.id, color: c.c } + } + } + return catsMap + } +} + +const parsePoints = (query: UrlQuery): FakeMap | undefined => { + // First: ensure there is a pts field + if (query.pts !== undefined && typeof query.pts === 'string') { + const pts = JSON.parse(query.pts) + const ptsMap: FakeMap = {} + for (const p of pts) { + // TODO: add more validation (domain of x and y, positive number) + if ( + p.id !== undefined && + typeof p.id === 'string' && + p.n !== undefined && + typeof p.n === 'number' && + p.x !== undefined && + typeof p.x === 'number' && + p.y !== undefined && + typeof p.y === 'number' && + p.c !== undefined && + typeof p.c === 'string' + ) { + ptsMap[p.id] = { + id: p.id, + number: p.n, + x: p.x, + y: p.y, + categoryId: p.c + } + } + } + return ptsMap + } +} + +export const parse = (query: UrlQuery): Composition | undefined => { + const id: string | undefined = parseId(query) + const src: string | undefined = parseImageSpec(query) + const cats: FakeMap | undefined = parseCategories(query) + const pts: FakeMap | undefined = parsePoints(query) + if ( + id === undefined && + src === undefined && + cats === undefined && + pts === undefined + ) { + // Default URL - nothing to do + return + } + if ( + id === undefined || + src === undefined || + cats === undefined || + pts === undefined + ) { + throw new ReferenceError('Missing URL arguments') + } + + // If some points refer to a non-existing category, throw + const catsIds = Object.keys(cats) + const hasValidCategoryId = (p: Point): boolean => + catsIds.includes(p.categoryId) + if (Object.values(pts).some(p => !hasValidCategoryId(p))) { + throw new RangeError("Some points in the URL don't match a category") + } + + // replacing ' ' by '+' seems to be required when pasting a data URL + // (https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) + // in the 'img=xxx' query parameter + const fixedSrc: string = src.replace(/ /g, '+') + + // All the fields of the composition seem to be valid + return { + id, + backgroundImage: { src: fixedSrc, exportableSrc: fixedSrc }, + categories: cats, + points: pts + } +} diff --git a/src/utils/router.ts b/src/utils/router.ts new file mode 100644 index 0000000..f743ebb --- /dev/null +++ b/src/utils/router.ts @@ -0,0 +1,45 @@ +import { Category, Composition, Point, UrlQuery, UrlQuerySpec, UrlQuerySpecPt, UrlQuerySpecCat } from '@/types' +import router from '@/router' + +const specToQuery = (query: UrlQuerySpec): UrlQuery => { + return { + id: query.id, + img: query.img, + cats: JSON.stringify(query.cats), + pts: JSON.stringify(query.pts) + } +} + +export const goTo = (spec: UrlQuerySpec) => { + router.push({ query: specToQuery(spec) }) +} + +export const goToComposition = (composition: Composition) => { + goTo(compositionToUrlQuerySpec(composition)) +} + +const pointToUrlQuerySpecPt = (p: Point): UrlQuerySpecPt => { + // TODO: force the points to be associed to a category? + return { + id: p.id, + n: p.number, + x: p.x, + y: p.y, + c: p.categoryId || '' + } +} +const pointToUrlQuerySpecCat = (c: Category): UrlQuerySpecCat => { + return { + id: c.id, + c: c.color + } +} + +export const compositionToUrlQuerySpec = (c: Composition): UrlQuerySpec => { + return { + id: c.id, + img: c.backgroundImage.exportableSrc, + cats: Object.values(c.categories).map(pointToUrlQuerySpecCat), + pts: Object.values(c.points).map(pointToUrlQuerySpecPt) + } +} diff --git a/src/utils/severo_pictures.ts b/src/utils/severo_pictures.ts deleted file mode 100644 index 6498b16..0000000 --- a/src/utils/severo_pictures.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ImageSrc } from '@/utils/types.ts' - -function forgeUrl (name: string, width: number): string { - return `https://github.com/severo/pictures/raw/master/images,w_${width}/${name}.jpg` -} - -const names: string[] = [ - 'petanque', - 'boats', - 'honeycomb', - 'spider', - 'wolves', - 'bazzania', - 'basketball', - 'beach', - 'cuzco' -] -const widths: number[] = [320, 640, 768, 1024, 1366, 1600, 1920] -const sortedWidth: number[] = [...widths].sort((a: number, b: number): number => a - b) -const minWidth: number = sortedWidth[0] -const maxWidth: number = sortedWidth[sortedWidth.length - 1] - -const imageSrcs: ImageSrc[] = names.map(name => { - return { - src: forgeUrl(name, maxWidth), - srcset: widths.map(w => `${forgeUrl(name, w)} ${w}w`).join(','), - thumbnailSrc: forgeUrl(name, minWidth) - } -}) - -export { imageSrcs } diff --git a/src/utils/types.ts b/src/utils/types.ts deleted file mode 100644 index 300cd4f..0000000 --- a/src/utils/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Images -export interface ImageSrc { - src: string; - srcset?: string; - thumbnailSrc?: string; -} - -// Points -export interface XY { - x: number; - y: number; -} -export interface XYId extends XY { - id: string; -} -export interface XYCategory extends XY { - categoryId: string; -} -export interface Point extends XYCategory { - id: string; - number: number; -} - -// Categories -export interface Color { - color: string; -} -export interface Category extends Color { - id: string; -} - -// Compositions -export interface ExportableComposition { - backgroundImage: ImageSrc; - points: Point[]; - categories: Category[]; -} diff --git a/src/views/Home.vue b/src/views/Home.vue new file mode 100644 index 0000000..451f9b3 --- /dev/null +++ b/src/views/Home.vue @@ -0,0 +1,202 @@ + + + + +