Parcourir la source

Merge branch 'release/v0.1.7'

rigwild il y a 5 ans
Parent
commit
d852aed44a

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "expe-web",
-  "version": "0.1.6",
+  "version": "0.1.7",
   "private": true,
   "scripts": {
     "server:start": "node -r esm server/index.js",

+ 35 - 6
server/routes/getImage.js

@@ -2,6 +2,7 @@
 
 import express from 'express'
 import path from 'path'
+import sharp from 'sharp'
 import boom from '@hapi/boom'
 
 import { imagesPath, imageServedUrl } from '../../config'
@@ -10,9 +11,9 @@ import { asyncMiddleware, checkSceneName, checkRequiredParameters, getSceneFiles
 const router = express.Router()
 
 /**
- * @api {get} /getImage?sceneName=:sceneName&imageQuality=:imageQuality&nearestQuality=:nearestQuality Get an image from a scene
+ * @api {get} /getImage?sceneName=:sceneName&imageQuality=:imageQuality&nearestQuality=:nearestQuality /getImage
  * @apiVersion 0.1.0
- * @apiName GetImage
+ * @apiName getImage
  * @apiGroup API
  *
  * @apiDescription Get an image from a scene with the required quality
@@ -24,7 +25,13 @@ const router = express.Router()
  * @apiExample Usage example
  * curl -i -L -X GET "http://diran.univ-littoral.fr/api/getImage?sceneName=bathroom&imageQuality=200"
  *
- * @apiSuccess {String} data Path to the image
+ * @apiSuccess {Object} data Informations on the image
+ * @apiSuccess {String} data.link Path to the image
+ * @apiSuccess {String} data.fileName File name of the image
+ * @apiSuccess {String} data.sceneName Scene name of the image
+ * @apiSuccess {Number} data.quality Quality of the image
+ * @apiSuccess {String} data.ext Extension of the image
+ * @apiSuccess {Object} data.metadata Metadata of the image, @see https://sharp.dimens.io/en/stable/api-input/#metadata
  * @apiSuccessExample {json} Success response example
  * HTTP/1.1 200 OK /api/getImage?sceneName=bathroom&imageQuality=200
  * {
@@ -33,7 +40,19 @@ const router = express.Router()
  *     "fileName": "bathroom_00200.png",
  *     "sceneName": "bathroom",
  *     "quality": 200,
- *     "ext": "png"
+ *     "ext": "png",
+ *     "metadata": {
+ *       "format": "png",
+ *        "width": 800,
+ *        "height": 800,
+ *        "space": "rgb16",
+ *        "channels": 3,
+ *        "depth": "ushort",
+ *        "density": 72,
+ *        "isProgressive": false,
+ *        "hasProfile": false,
+ *        "hasAlpha": false
+ *      }
  *   }
  * }
  *
@@ -138,8 +157,10 @@ export const getImage = async (sceneName, quality, nearestQuality = false) => {
     else imageData = sceneData.find(x => quality === x.quality)
   }
 
-  if (imageData)
-    return {
+
+  if (imageData) {
+    // Data gathered from file system
+    const result = {
       link: `${imageServedUrl}/${sceneName}/${imageData.fileName}`,
       path: path.resolve(imagesPath, sceneName, imageData.fileName),
       fileName: imageData.fileName,
@@ -148,6 +169,14 @@ export const getImage = async (sceneName, quality, nearestQuality = false) => {
       ext: imageData.ext
     }
 
+    // Data gathered by analysing the image
+    const input = sharp(result.path)
+    const metadata = await input.metadata()
+    result.metadata = metadata
+
+    return result
+  }
+
   // Image not found
   throw boom.notFound(`The requested quality "${quality}" was not found for the requested scene "${sceneName}".`)
 }

+ 62 - 12
server/routes/getImageExtracts.js

@@ -13,12 +13,12 @@ import { getImage } from './getImage'
 const router = express.Router()
 
 /**
- * @api {get} /getImageExtracts?sceneName=:sceneName&imageQuality=:imageQuality&horizontalExtractCount=:horizontalExtractCount&verticalExtractCount=:verticalExtractCount&nearestQuality=:nearestQuality Get image extracts
+ * @api {get} /getImageExtracts?sceneName=:sceneName&imageQuality=:imageQuality&horizontalExtractCount=:horizontalExtractCount&verticalExtractCount=:verticalExtractCount&nearestQuality=:nearestQuality /getImageExtracts
  * @apiVersion 0.1.0
- * @apiName GetImageExtracts
+ * @apiName getImageExtracts
  * @apiGroup API
  *
- * @apiDescription Get an image from a scene with the required quality and cut it with the requested configuration
+ * @apiDescription Get an image from a scene with the required quality and cut it into multiple extracts with the requested configuration
  *
  * @apiParam {String} sceneName The selected scene
  * @apiParam {String="min","max","median", "any integer"} imageQuality The required quality of the image (can be an integer, `min`, `max` or `median`)
@@ -29,21 +29,57 @@ const router = express.Router()
  * @apiExample Usage example
  * curl -i -L -X GET "http://diran.univ-littoral.fr/api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2"
  *
- * @apiSuccess {String[]} data Path to the extracted images
+ * @apiSuccess {Object} data Path to the extracted images
+ * @apiSuccess {String[]} data.extracts Path to the extracted images
+ * @apiSuccess {Object} data.info Informations on the original image
+ * @apiSuccess {String} data.info.link Path to the original image
+ * @apiSuccess {String} data.info.fileName File name of the original image
+ * @apiSuccess {String} data.info.sceneName Scene name of the original image
+ * @apiSuccess {Number} data.info.quality Quality of the original image
+ * @apiSuccess {String} data.info.ext Extension of the original image
+ * @apiSuccess {Object} data.metadata Metadata of the image, @see https://sharp.dimens.io/en/stable/api-input/#metadata
+ * @apiSuccess {Object} data.info.extractsConfig Configuration used to cut the image
+ * @apiSuccess {Number} data.info.extractsConfig.x Number of extracts per line (horizontal)
+ * @apiSuccess {Number} data.info.extractsConfig.y Number of extracts per row (vertical)
+ * @apiSuccess {Object} data.info.extractsSize Size of extracted images
+ * @apiSuccess {Number} data.info.extractsSize.width Width of the extracted images
+ * @apiSuccess {Number} data.info.extractsSize.height Height of the extracted images
  * @apiSuccessExample {json} Success response example
  * HTTP/1.1 200 OK /api/getImageExtracts?sceneName=bathroom&imageQuality=200&horizontalExtractCount=1&verticalExtractCount=2
  * {
  *   "data": {
- *     extracts: [
+ *     "extracts": [
  *       "/api/images/bathroom/extracts/x1_y2/zone00001/bathroom_zone00001_200.png",
  *       "/api/images/bathroom/extracts/x1_y2/zone00002/bathroom_zone00002_200.png"
  *     ],
  *     "info": {
- *       "link": "/api/images/bathroom/bathroom_00200.png",
- *       "fileName": "bathroom_00200.png",
- *       "sceneName": "bathroom",
- *       "quality": 200,
- *       "ext": "png"
+ *       "extractsConfig": {
+ *         "x": 1,
+ *         "y": 2
+ *       },
+ *       "extractsSize": {
+ *         "width": 800,
+ *         "height": 400
+ *       },
+ *       "image": {
+ *         "link": "/api/images/bathroom/bathroom_00200.png",
+ *         "fileName": "bathroom_00200.png",
+ *         "sceneName": "bathroom",
+ *         "quality": 200,
+ *         "ext": "png"
+ *         "metadata": {
+ *           "format": "png",
+ *            "width": 800,
+ *            "height": 800,
+ *            "space": "rgb16",
+ *            "channels": 3,
+ *            "depth": "ushort",
+ *            "density": 72,
+ *            "isProgressive": false,
+ *            "hasProfile": false,
+ *            "hasAlpha": false
+ *          }
+ *       }
  *     }
  *   }
  * }
@@ -162,7 +198,11 @@ const cutImage = async (image, xExtracts, yExtracts) => {
         link: extractLink,
         path: extractPath,
         fileName: extractName,
-        sceneName: image.sceneName
+        sceneName: image.sceneName,
+        originalWidth: width,
+        originalHeight: height,
+        width: xCropSize,
+        height: yCropSize
       }
 
       // Check the file already exist
@@ -266,7 +306,17 @@ router.get('/', asyncMiddleware(async (req, res) => {
   res.json({
     data: {
       extracts: extracts.map(x => x.link),
-      info: image
+      info: {
+        extractsConfig: {
+          x: horizontalExtractCountInt,
+          y: verticalExtractCountInt
+        },
+        extractsSize: {
+          width: extracts[0].width,
+          height: extracts[0].height
+        },
+        image
+      }
     }
   })
 }))

+ 2 - 1
server/routes/index.js

@@ -5,6 +5,7 @@ import listScenes from './listScenes'
 import listSceneQualities from './listSceneQualities'
 import getImage from './getImage'
 import getImageExtracts from './getImageExtracts'
+import ping from './ping'
 
 const router = express.Router()
 
@@ -12,6 +13,6 @@ router.use('/listScenes', listScenes)
 router.use('/listSceneQualities', listSceneQualities)
 router.use('/getImage', getImage)
 router.use('/getImageExtracts', getImageExtracts)
-router.get('/ping', (req, res) => res.send('pong'))
+router.get('/ping', ping)
 
 export default router

+ 2 - 2
server/routes/listSceneQualities.js

@@ -6,9 +6,9 @@ import { asyncMiddleware, checkRequiredParameters, getSceneFilesData } from '../
 const router = express.Router()
 
 /**
- * @api {get} /listSceneQualities?sceneName=:sceneName Get a list of available qualities for a scene
+ * @api {get} /listSceneQualities?sceneName=:sceneName /listSceneQualities
  * @apiVersion 0.1.0
- * @apiName ListScenesQualities
+ * @apiName listScenesQualities
  * @apiGroup API
  *
  * @apiDescription List all available qualities for a given scene

+ 2 - 2
server/routes/listScenes.js

@@ -10,9 +10,9 @@ import { imagesPath } from '../../config'
 const router = express.Router()
 
 /**
- * @api {get} /listScenes Get a list of all available scenes
+ * @api {get} /listScenes /listScenes
  * @apiVersion 0.1.0
- * @apiName GetListScenes
+ * @apiName listScenes
  * @apiGroup API
  *
  * @apiDescription List all scenes availables in your `IMAGES_PATH` directory

+ 25 - 0
server/routes/ping.js

@@ -0,0 +1,25 @@
+'use strict'
+
+import express from 'express'
+
+const router = express.Router()
+
+/**
+ * @api {get} /ping /ping
+ * @apiVersion 0.1.0
+ * @apiName ping
+ * @apiGroup API
+ *
+ * @apiDescription Check if the API is up
+ *
+ * @apiExample Usage example
+ * curl -i -L -X GET "http://diran.univ-littoral.fr/api/ping"
+ *
+ * @apiSuccessExample {string} Success response example
+ * HTTP/1.1 200 OK /api/ping
+ * pong
+ */
+
+router.get('/ping', (req, res) => res.send('pong'))
+
+export default router

+ 4 - 1
server/webSocket/index.js

@@ -33,7 +33,10 @@ const createWsServer = httpServer => {
   wss.on('listening', () => wsLogger.info(formatLog('The WebSocket server was started')))
   wss.on('error', err => wsLogger.error(formatError(err)))
 
-  wss.on('connection', ws => {
+  wss.on('connection', (ws, req) => {
+    // Unique identifier passed with the request url
+    ws.uuid = req.url.replace('/?uuid=', '')
+
     wsLogger.info(formatLog('New client connected.'))
 
     ws.on('message', data => messageHandler(ws)(data).catch(err => errorHandler(ws)(err)))

+ 1 - 0
server/webSocket/messageHandler.js

@@ -22,6 +22,7 @@ const messageHandler = ws => async data => {
     throw new Error('Invalid JSON data.')
   }
 
+  json.WS_UNIQUE_UUID = ws.uuid
   await DataController.add(json)
   if (!TEST_MODE) wsLogger.info(formatLog(json, 'message'))
   ws.send('{"message":"ok"}')

+ 9 - 10
src/App.vue

@@ -98,19 +98,17 @@ export default {
     ...mapGetters(['isHostConfigured', 'areScenesLoaded'])
   },
   watch: {
-    isHostConfigured(value) {
-      if (value) this.loadAppData()
+    isHostConfigured(isConfigured) {
+      if (isConfigured) this.loadScenes()
     }
   },
-  mounted() {
-    this.loadAppData()
+  async mounted() {
+    this.setAppUniqueId()
+    if (this.isHostConfigured) await this.loadWebSocket()
+    if (this.isHostConfigured && !this.areScenesLoaded) await this.loadScenes()
   },
   methods: {
-    ...mapActions(['loadScenesList', 'connectToWs']),
-    async loadAppData() {
-      if (this.isHostConfigured) await this.loadWebSocket()
-      if (this.isHostConfigured && !this.areScenesLoaded) await this.loadScenes()
-    },
+    ...mapActions(['setAppUniqueId', 'loadScenesList', 'connectToWs']),
 
     async load(fn, loadingMessage) {
       try {
@@ -118,6 +116,7 @@ export default {
         await fn()
       }
       catch (err) {
+        console.error(err)
         this.loadingErrorMessage = err.message
         return
       }
@@ -139,7 +138,7 @@ export default {
 
 <style scoped>
 .reset-button {
-  position: absolute;
+  position: fixed;
   right: 0;
   bottom: 0;
   z-index: 999;

+ 7 - 2
src/components/HostConfig.vue

@@ -13,7 +13,7 @@
                   <v-flex xs3>
                     <v-select
                       v-model="config.ssl"
-                      :items="[true, false]"
+                      :items="[false, true]"
                       label="SSL"
                     />
                   </v-flex>
@@ -63,7 +63,7 @@ export default {
   data() {
     return {
       config: {
-        ssl: true,
+        ssl: false,
         host: 'diran.univ-littoral.fr',
         port: '80'
       },
@@ -79,6 +79,10 @@ export default {
     }
   },
 
+  mounted() {
+    this.$router.push('/')
+  },
+
   methods: {
     ...mapActions(['setHostConfig']),
     reset() {
@@ -97,6 +101,7 @@ export default {
         await this.setHostConfig(this.config)
       }
       catch (err) {
+        console.error(err)
         this.configErrorMessage = err.message
         return
       }

+ 3 - 2
src/components/Loader.vue

@@ -7,7 +7,7 @@
           color="primary"
           indeterminate
         />
-        <div class="mt-3">{{ message }}</div>
+        <div class="mt-3" v-if="message">{{ message }}</div>
       </v-layout>
     </v-container>
   </v-content>
@@ -19,7 +19,8 @@ export default {
   props: {
     message: {
       type: String,
-      default: 'Loading...'
+      default: null,
+      required: false
     }
   }
 }

+ 2 - 1
src/components/ResetAppButton.vue

@@ -115,7 +115,8 @@ export default {
         this.showDialog = false
       }
       catch (err) {
-        this.$refs.toast.show(err.message, 'error', 10000)
+        console.error('Failed to reset the app', err)
+        this.$refs.toast.show('Failed to reset the app. ' + err.message, 'error', 10000)
       }
       this.$router.push('/')
     }

+ 23 - 3
src/functions.js

@@ -4,12 +4,12 @@ export const API_ROUTES = {
 
   listScenes: () => `${API_PREFIX}/listScenes`,
 
-  listSceneQualities: sceneName => `${API_PREFIX}/listSceneQualities?sceneName=${new URLSearchParams({ sceneName })}`,
+  listSceneQualities: sceneName => `${API_PREFIX}/listSceneQualities?${new URLSearchParams({ sceneName })}`,
 
   getImage: (sceneName, imageQuality, nearestQuality = false) => `${API_PREFIX}/getImage?${new URLSearchParams({ sceneName, imageQuality, nearestQuality })}`,
 
   getImageExtracts: (sceneName, imageQuality, horizontalExtractCount, verticalExtractCount, nearestQuality = false) =>
-    `${API_PREFIX}/getImage?${new URLSearchParams({
+    `${API_PREFIX}/getImageExtracts?${new URLSearchParams({
       sceneName,
       imageQuality,
       horizontalExtractCount,
@@ -21,4 +21,24 @@ export const API_ROUTES = {
 export const delay = ms => new Promise(res => setTimeout(res, ms))
 
 export const buildURI = (ssl, host, port, route = '') => `${ssl ? 'https' : 'http'}://${host}:${port}${route}`
-export const buildWsURI = (ssl, host, port) => `${ssl ? 'wss' : 'ws'}://${host}:${port}`
+export const buildWsURI = (ssl, host, port, uuid = '') => `${ssl ? 'wss' : 'ws'}://${host}:${port}?uuid=${uuid}`
+
+export const sortIntArray = intArray => intArray.sort((a, b) => a - b)
+
+export const findNearestUpper = (value, arrInt) => {
+  const arr = sortIntArray(arrInt)
+  const index = arr.findIndex(x => value === x)
+  if (index >= 0 && index <= arr.length - 1)
+    return index === arr.length - 1
+      ? arr[index]
+      : arr[index + 1]
+}
+
+export const findNearestLower = (value, arrInt) => {
+  const arr = sortIntArray(arrInt)
+  const index = arr.findIndex(x => value === x)
+  if (index >= 0 && index <= arr.length - 1)
+    return index === 0
+      ? arr[index]
+      : arr[index - 1]
+}

+ 1 - 1
src/main.js

@@ -11,7 +11,7 @@ Vue.use(VueNativeSock, 'ws://example.com', {
   store,
   connectManually: true,
   reconnection: true,
-  reconnectionAttempts: 5,
+  reconnectionAttempts: 2,
   reconnectionDelay: 1000
 })
 store.$socket = Vue.prototype.$socket

+ 15 - 3
src/store/actions.js

@@ -1,12 +1,15 @@
 import Vue from 'vue'
-import { API_ROUTES, buildURI, buildWsURI } from '../functions'
+import { API_ROUTES, buildURI, buildWsURI, delay } from '../functions'
 
 export default {
+  setAppUniqueId({ state, commit }) {
+    if (!state.uuid) commit('setAppUniqueId')
+  },
+
   resetApp({ commit }, { hostConfig = false, progression = false }) {
     commit('resetApp', { hostConfig, progression })
   },
-
-  async setHostConfig({ commit }, { ssl, host, port }) {
+  async setHostConfig({ state, commit }, { ssl, host, port }) {
     // Timeout after 1s
     const controller = new AbortController()
     const signal = controller.signal
@@ -22,6 +25,11 @@ export default {
 
         this._vm.$connect(buildWsURI(ssl, host, port))
 
+        // $connect does not return a Promise, so we wait to know if it worked
+        await delay(300)
+        if (!state.socket.isConnected)
+          throw new Error('Could not connect to remote WebSocket server.')
+
         // Configuration is valid
         commit('setHostConfig', { ssl, host, port })
       })
@@ -35,6 +43,10 @@ export default {
     if (state.socket.isConnected) return /*eslint-disable-line */
     else if (getters.isHostConfigured) {
       this._vm.$connect(getters.getHostWsURI)
+      // $connect does not return a Promise, so we wait to know if it worked
+      await delay(300)
+      if (!state.socket.isConnected)
+        throw new Error('Could not connect to remote WebSocket server.')
     }
     else throw new Error('Could not connect to WebSocket server. Host is not configured.')
   },

+ 5 - 1
src/store/getters.js

@@ -2,19 +2,23 @@ import { buildURI, buildWsURI } from '../functions'
 
 export default {
   isHostConfigured(state) {
+    if (!state) return
     return !!(state.hostConfig.ssl !== null && state.hostConfig.host && state.hostConfig.port)
   },
   getHostURI(state, getters) {
+    if (!state) return
     if (getters.isHostConfigured)
       return buildURI(state.hostConfig.ssl, state.hostConfig.host, state.hostConfig.port)
   },
 
   getHostWsURI(state, getters) {
+    if (!state) return
     if (getters.isHostConfigured)
-      return buildWsURI(state.hostConfig.ssl, state.hostConfig.host, state.hostConfig.port)
+      return buildWsURI(state.hostConfig.ssl, state.hostConfig.host, state.hostConfig.port, state.uuid)
   },
 
   areScenesLoaded(state) {
+    if (!state) return
     return state.scenesList !== null
   }
 

+ 1 - 0
src/store/index.js

@@ -12,6 +12,7 @@ const vuexLocal = new VuexPersistence({
   storage: window.localStorage,
   key: 'webexpe-state',
   reducer: state => ({
+    uuid: state.uuid,
     hostConfig: state.hostConfig,
     scenesList: state.scenesList,
     progression: state.progression

+ 20 - 6
src/store/mutations.js

@@ -1,5 +1,5 @@
 import Vue from 'vue'
-import { defaultState } from '@/store/state'
+import defaultState from '@/store/state'
 import Experiments from '@/router/experiments'
 
 const checkProgression = (state, experimentName, sceneName) => {
@@ -10,9 +10,17 @@ const checkProgression = (state, experimentName, sceneName) => {
 }
 
 export default {
+  setAppUniqueId(state) {
+    state.uuid = [...Array(30)].map(() => Math.random().toString(36)[2]).join('')
+  },
+
   resetApp(state, { hostConfig, progression }) {
-    if (hostConfig) state.hostConfig = defaultState.hostConfig
-    if (progression) state.progression = defaultState.progression
+    if (hostConfig) {
+      if (state.socket.isConnected)
+        this._vm.$disconnect()
+      state.hostConfig = defaultState().hostConfig
+    }
+    if (progression) state.progression = defaultState().progression
   },
 
   setHostConfig(state, newConfig) {
@@ -46,14 +54,19 @@ export default {
   },
 
   SOCKET_ONOPEN(state, event) {
+    if (event === null) return
+
+    console.info('Connected to WebSocket server')
     Vue.prototype.$socket = event.currentTarget
     state.socket.isConnected = true
   },
-  SOCKET_ONCLOSE(state, event) {
+  SOCKET_ONCLOSE(state, _event) {
+    console.info('Disconnected from WebSocket server')
+    state.hostConfig = defaultState().hostConfig
     state.socket.isConnected = false
   },
   SOCKET_ONERROR(state, event) {
-    console.error(state, event)
+    console.error('WebSocket connection error', state, event)
   },
   // default handler called for all methods
   SOCKET_ONMESSAGE(state, { data: rawMessage }) {
@@ -62,9 +75,10 @@ export default {
   },
   // mutations for reconnect methods
   SOCKET_RECONNECT(state, count) {
-    console.info(state, count)
+    console.info('Reconnect to WebSocket server', state, count)
   },
   SOCKET_RECONNECT_ERROR(state) {
+    console.error('Could not reconnect to WebSocket server')
     state.socket.reconnectError = true
   }
 }

+ 1 - 0
src/store/state.js

@@ -1,5 +1,6 @@
 // Deep copy to not mutate it with the store (default state is needed when reloading after a refresh)
 export default () => JSON.parse(JSON.stringify({
+  uuid: null,
   hostConfig: {
     ssl: null,
     host: null,

+ 236 - 15
src/views/Experiments/WithReference.vue

@@ -2,44 +2,265 @@
   <div>
     <v-container grid-list-md text-xs-center fluid>
       <v-layout row wrap>
-        <v-flex xs6>
-          <v-card dark color="primary">
-            <v-card-text class="px-0">Experience image</v-card-text>
-            <v-img src="https://diran.univ-littoral.fr/api/images/Appart1opt02/appartAopt_00900.png" />
-          </v-card>
-        </v-flex>
-        <v-flex xs6>
-          <v-card dark color="primary">
-            <v-card-text>Reference image</v-card-text>
-            <v-img src="https://diran.univ-littoral.fr/api/images/Appart1opt02/appartAopt_00900.png" />
+        <v-flex xs12>
+          <h1>Experiment with reference</h1>
+          <v-card dark>
+            <v-container grid-list-sm fluid>
+              <v-layout row wrap>
+                <v-flex
+                  xs12
+                >
+                  <h1>Configuration</h1>
+                  <v-card-text class="px-0">Extracts per line (horizontal)</v-card-text>
+                  <v-slider
+                    v-model="experimentConfig.x"
+                    always-dirty
+                    persistent-hint
+                    thumb-label="always"
+                    min="1"
+                    max="25"
+                  />
+
+                  <v-card-text class="px-0">Extracts per row (vertical)</v-card-text>
+                  <v-slider
+                    v-model="experimentConfig.y"
+                    always-dirty
+                    persistent-hint
+                    thumb-label="always"
+                    min="1"
+                    max="25"
+                  />
+
+                  <v-btn @click="setConfig" :disabled="!isConfigNew">Confirm</v-btn>
+
+                  <v-alert v-if="loadingErrorMessage" :value="true" type="error" v-text="loadingErrorMessage" />
+                </v-flex>
+              </v-layout>
+            </v-container>
           </v-card>
         </v-flex>
+        <!-- Loading screen -->
+        <loader v-if="loadingMessage" :message="loadingMessage" />
+        <!--/ Loading screen -->
+
+        <!-- Experiment -->
+        <template v-else-if="!loadingErrorMessage">
+          <v-flex xs12 sm6>
+            <v-card dark color="primary">
+              <v-card-text class="px-0">Experiment image</v-card-text>
+
+              <v-container class="pa-1">
+                <template v-for="i in extractConfig.x">
+                  <v-layout row wrap :key="`row-${i}`">
+                    <v-flex
+                      v-for="(anExtract, index) in extracts.slice(extractConfig.x * (i - 1), (extractConfig.x * i))"
+                      :key="`extract-${i}-${extractConfig.x}-${extractConfig.y}-${index}-${anExtract.quality}`"
+                      class="pa-0"
+                    >
+                      <v-card flat tile class="d-flex height100">
+                        <div
+                          v-if="anExtract.loading"
+                          class="img-loader"
+                          @click.right.prevent
+                        >
+                          <v-progress-circular
+                            :indeterminate="true"
+                          />
+                        </div>
+                        <v-img
+                          v-else
+                          :src="anExtract.link"
+                          @click.left.prevent="extractAction($event, anExtract)"
+                          @click.right.prevent="extractAction($event, anExtract)"
+                          class="cursor extract"
+                        >
+                          <template v-slot:placeholder>
+                            <v-layout
+                              fill-height
+                              align-center
+                              justify-center
+                              ma-0
+                            >
+                              <v-progress-circular indeterminate color="grey lighten-5" />
+                            </v-layout>
+                          </template>
+                        </v-img>
+                      </v-card>
+                    </v-flex>
+                  </v-layout>
+                </template>
+              </v-container>
+            </v-card>
+          </v-flex>
+          <v-flex sm6 xs12>
+            <v-card dark color="primary">
+              <v-card-text>Reference image</v-card-text>
+              <v-img v-if="referenceImage" :src="referenceImage" />
+            </v-card>
+          </v-flex>
+        </template>
+      <!--/ Experiment -->
       </v-layout>
     </v-container>
   </div>
 </template>
 
 <script>
-import { mapGetters } from 'vuex'
-import { API_ROUTES } from '@/functions'
+import { mapGetters, mapActions } from 'vuex'
+import { API_ROUTES, findNearestUpper, findNearestLower } from '@/functions'
+import Loader from '@/components/Loader.vue'
 
 export default {
   name: 'ExperimentWithReference',
+  components: {
+    Loader
+  },
   props: {
     sceneId: {
       type: String,
       required: true
     }
   },
+  data() {
+    return {
+      referenceImage: null,
+      qualities: null,
+
+      experimentConfig: { // Experiment config sliders
+        x: 8,
+        y: 4,
+        error: null
+      },
+      extractConfig: { // Updated when `setConfig` is called
+        x: 8,
+        y: 4
+      },
+      loadingMessage: null,
+      loadingErrorMessage: null,
+      extracts: []
+    }
+  },
   computed: {
+    ...mapGetters(['getHostURI']),
+    isConfigNew() {
+      return this.extractConfig.x !== this.experimentConfig.x || this.extractConfig.y !== this.experimentConfig.y
+    }
   },
+
   async mounted() {
-    await this.getExtracts()
+    await this.getReferenceImage()
+    await this.getQualitiesList()
+
+    // Get default extracts : min quality, cut config : x = 4, y = 4
+    await this.setConfig()
   },
   methods: {
-    async getExtracts() {
-      const scenes = await fetch(`${this.getHostURI}${API_ROUTES.getImage()}`).then(res => res.json())
+    ...mapActions([]),
+
+    async getReferenceImage() {
+      const URI = `${this.getHostURI}${API_ROUTES.getImage(this.sceneId, 'max')}`
+      const { data } = await fetch(URI).then(res => res.json())
+      this.referenceImage = this.getHostURI + data.link
+    },
+
+    async getQualitiesList() {
+      const URI = `${this.getHostURI}${API_ROUTES.listSceneQualities(this.sceneId)}`
+      const { data } = await fetch(URI).then(res => res.json())
+      this.qualities = data
+    },
+
+    async getExtracts(quality = 'max') {
+      const URI = `${this.getHostURI}${API_ROUTES.getImageExtracts(this.sceneId, quality, this.extractConfig.x, this.extractConfig.y)}`
+      const { data } = await fetch(URI)
+        .then(async res => {
+          res.json = await res.json()
+          return res
+        })
+        .then(res => {
+          if (!res.ok) throw new Error(res.json.message + res.json.data ? `\n${res.json.data}` : '')
+          return res.json
+        })
+      return data
+    },
+
+    async setConfig() {
+      // Check if the config is the same
+      if (this.extracts.length > 0 && !this.isConfigNew) return
+
+      this.loadingMessage = 'Loading configuration extracts...'
+      this.loadingErrorMessage = null
+      try {
+        this.extractConfig.x = this.experimentConfig.x
+        this.extractConfig.y = this.experimentConfig.y
+        const data = await this.getExtracts()
+        const hostURI = this.getHostURI
+        this.extracts = data.extracts.map((url, i) => ({
+          link: hostURI + url,
+          quality: data.info.image.quality,
+          zone: i + 1,
+          index: i,
+          nextQuality: findNearestUpper(data.info.image.quality, this.qualities),
+          precQuality: findNearestLower(data.info.image.quality, this.qualities),
+          loading: false
+        }))
+      }
+      catch (err) {
+        console.error('Failed to load new configuration', err)
+        this.loadingErrorMessage = 'Failed to load new configuration. ' + err.message
+      }
+      this.loadingMessage = null
+    },
+
+    async extractAction(event, extractObj) {
+      console.log(event, extractObj)
+      const { index, nextQuality, precQuality, quality } = extractObj
+
+      let newQuality
+      if (event.button === 0) newQuality = precQuality // Left click
+      if (event.button === 2) newQuality = nextQuality // Right click
+
+      // Do not load a new extract if same quality
+      if (newQuality === quality) return
+
+      // Set loading state
+      this.extracts[index].loading = true
+      try {
+        // Loading new extract
+        const data = await this.getExtracts(newQuality)
+        this.extracts[index].link = this.getHostURI + data.extracts[index]
+        this.extracts[index].quality = data.info.image.quality
+        this.extracts[index].nextQuality = findNearestUpper(data.info.image.quality, this.qualities)
+        this.extracts[index].precQuality = findNearestLower(data.info.image.quality, this.qualities)
+        this.extracts[index].loading = false
+      }
+      catch (err) {
+        // TODO: toast message if fail
+        console.error('Failed to load extract', err)
+      }
+      finally {
+        this.extracts[index].loading = false
+      }
     }
   }
 }
 </script>
+
+<style scoped>
+.height100 {
+  height: 100%;
+}
+.img-loader {
+  height: 100%;
+  width: 0px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.cursor {
+  cursor: pointer;
+}
+.extract:hover {
+  z-index: 999999;
+  outline: 2px #f4f4f4 solid;
+}
+</style>

+ 17 - 5
test/api/getImage.js

@@ -94,11 +94,23 @@ test('GET /getImage?sceneName=bathroom&imageQuality=10', async t => {
   t.is(res.status, 200, json(res))
   t.is(res.body.data.link, `${imageServedUrl}/bathroom/bathroom_00010.png`, json(res.body))
   t.deepEqual(res.body.data, {
-    'link': `${imageServedUrl}/bathroom/bathroom_00010.png`,
-    'fileName': 'bathroom_00010.png',
-    'sceneName': 'bathroom',
-    'quality': 10,
-    'ext': 'png'
+    link: `${imageServedUrl}/bathroom/bathroom_00010.png`,
+    fileName: 'bathroom_00010.png',
+    sceneName: 'bathroom',
+    quality: 10,
+    ext: 'png',
+    metadata: {
+      format: 'png',
+      width: 800,
+      height: 800,
+      space: 'srgb',
+      channels: 3,
+      depth: 'uchar',
+      density: 72,
+      isProgressive: false,
+      hasProfile: false,
+      hasAlpha: false
+    }
   }, json(res.body))
 
   // Check link is accessible and is an image

+ 27 - 5
test/api/getImageExtracts.js

@@ -115,11 +115,33 @@ test.serial('GET /getImageExtracts?sceneName=bathroom&imageQuality=10&horizontal
   t.true(Array.isArray(res.body.data.extracts), json(res.body))
   t.is(res.body.data.extracts[0], `${imageServedUrl}/bathroom/extracts/x5_y2/zone00001/bathroom_zone00001_10.png`, json(res.body))
   t.deepEqual(res.body.data.info, {
-    link: `${imageServedUrl}/bathroom/bathroom_00010.png`,
-    fileName: 'bathroom_00010.png',
-    sceneName: 'bathroom',
-    quality: 10,
-    ext: 'png'
+    extractsConfig: {
+      x: 5,
+      y: 2
+    },
+    extractsSize: {
+      width: 160,
+      height: 400
+    },
+    image: {
+      link: `${imageServedUrl}/bathroom/bathroom_00010.png`,
+      fileName: 'bathroom_00010.png',
+      sceneName: 'bathroom',
+      quality: 10,
+      ext: 'png',
+      metadata: {
+        format: 'png',
+        width: 800,
+        height: 800,
+        space: 'srgb',
+        channels: 3,
+        depth: 'uchar',
+        density: 72,
+        isProgressive: false,
+        hasProfile: false,
+        hasAlpha: false
+      }
+    }
   }, json(res.body))
 
   // Check link is accessible and is an image

+ 19 - 0
test/api/ping.js

@@ -0,0 +1,19 @@
+'use strict'
+
+import test from 'ava'
+import request from 'supertest'
+import { apiPrefix } from '../../config'
+import { json, getHttpServer } from './_test_functions'
+
+// ROUTE /ping
+
+// Before each tests, start a server
+test.beforeEach(async t => (t.context.server = await getHttpServer()))
+
+test('GET /ping', async t => {
+  const res = await request(t.context.server)
+    .get(`${apiPrefix}/ping`)
+
+  t.is(res.status, 200, json(res))
+  t.is(res.text, 'pong')
+})