Merge branch 'fewer-ui-assumptions' into 'v3'

UI customises to available Things

Closes #510 and #385

See merge request openflexure/openflexure-microscope-server!435
This commit is contained in:
Julian Stirling 2025-11-11 14:36:22 +00:00
commit 5074f75442
27 changed files with 886 additions and 811 deletions

193
webapp/package-lock.json generated
View file

@ -29,7 +29,6 @@
"eslint-config-prettier": "^8.10.2", "eslint-config-prettier": "^8.10.2",
"eslint-plugin-prettier": "^3.4.1", "eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-vue": "^7.20.0", "eslint-plugin-vue": "^7.20.0",
"genversion": "^3.2.0",
"less": "^4.4.2", "less": "^4.4.2",
"less-loader": "^11.1.4", "less-loader": "^11.1.4",
"material-symbols": "^0.39.1", "material-symbols": "^0.39.1",
@ -7480,39 +7479,6 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@ -7566,16 +7532,6 @@
"url": "https://github.com/avajs/find-cache-dir?sponsor=1" "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
} }
}, },
"node_modules/find-package": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/find-package/-/find-package-1.0.0.tgz",
"integrity": "sha512-yVn71XCCaNgxz58ERTl8nA/8YYtIQDY9mHSrgFBfiFtdNNfY0h183Vh8BRkKxD8x9TUw3ec290uJKhDVxqGZBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"parents": "^1.0.1"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@ -7787,40 +7743,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/genversion": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/genversion/-/genversion-3.2.0.tgz",
"integrity": "sha512-OIYSX6XYA8PHecLDCTri30hadSZfAjZ8Iq1+BBDXqLWP4dRLuJNLoNjsSWtTpw97IccK2LDWzkEstxAB8GdN7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^7.2.0",
"ejs": "^3.1.9",
"find-package": "^1.0.0"
},
"bin": {
"genversion": "bin/genversion.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/genversion/node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -8646,101 +8568,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/jake": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
"integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.3",
"chalk": "^4.0.2",
"filelist": "^1.0.4",
"minimatch": "^3.1.2"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jake/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jake/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jake/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/jake/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/jake/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/jake/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/javascript-stringify": { "node_modules/javascript-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
@ -10231,16 +10058,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parents": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz",
"integrity": "sha512-mXKF3xkoUt5td2DoxpLmtOmZvko9VfFpwRwkKDHSNvgmpLAeBo18YDhcPbBzJq+QLCHMbGOfzia2cX4U+0v9Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-platform": "~0.11.15"
}
},
"node_modules/parse-json": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -10336,16 +10153,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true "dev": true
}, },
"node_modules/path-platform": {
"version": "0.11.15",
"resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz",
"integrity": "sha512-Y30dB6rab1A/nfEKsZxmr01nUotHX0c/ZiIAsCTatEe1CmS5Pm5He7fZ195bPT7RdquoaL8lLxFCMQi/bS7IJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",

View file

@ -6,8 +6,8 @@
"author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)", "author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)",
"scripts": { "scripts": {
"gv": "genversion src/version.js", "gv": "genversion src/version.js",
"serve": "npm run gv && vue-cli-service serve --mode development", "serve": "vue-cli-service serve --mode development",
"build": "npm run gv && vue-cli-service build --mode production", "build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --no-fix --max-warnings 0", "lint": "vue-cli-service lint --no-fix --max-warnings 0",
"lint:fix": "vue-cli-service lint --fix" "lint:fix": "vue-cli-service lint --fix"
}, },
@ -32,7 +32,6 @@
"eslint-config-prettier": "^8.10.2", "eslint-config-prettier": "^8.10.2",
"eslint-plugin-prettier": "^3.4.1", "eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-vue": "^7.20.0", "eslint-plugin-vue": "^7.20.0",
"genversion": "^3.2.0",
"less": "^4.4.2", "less": "^4.4.2",
"less-loader": "^11.1.4", "less-loader": "^11.1.4",
"material-symbols": "^0.39.1", "material-symbols": "^0.39.1",

View file

@ -1,5 +1,7 @@
<template> <template>
<div id="app" class="uk-height-1-1 uk-margin-remove uk-padding-remove" :class="handleTheme"> <div id="app" class="uk-height-1-1 uk-margin-remove uk-padding-remove" :class="handleTheme">
<!-- this stops the app loading until setConnected is committed in the store, this means
other components will not load until we have Thing Descriptions. -->
<loadingContent v-if="!$store.getters.ready" /> <loadingContent v-if="!$store.getters.ready" />
<appContent v-if="$store.getters.ready" /> <appContent v-if="$store.getters.ready" />
<!-- Runtime modals --> <!-- Runtime modals -->
@ -213,8 +215,8 @@ export default {
// TODO: should we purge existing consumedThings? // TODO: should we purge existing consumedThings?
try { try {
await this.$store.dispatch("wot/fetchThingDescriptions", `${baseUri}/thing_descriptions/`); await this.$store.dispatch("wot/fetchThingDescriptions", `${baseUri}/thing_descriptions/`);
for (let requiredThing of ["camera", "stage"]) { for (let requiredThing of ["camera", "system"]) {
if (!this.$store.getters["wot/thingAvailable"](requiredThing)) { if (!this.thingAvailable(requiredThing)) {
throw new Error(`No ${requiredThing} found, the GUI won't work without one.`); throw new Error(`No ${requiredThing} found, the GUI won't work without one.`);
} }
} }
@ -225,6 +227,7 @@ export default {
} catch { } catch {
this.$store.commit("changeMicroscopeHostname", null); this.$store.commit("changeMicroscopeHostname", null);
} }
// start rendering components
this.$store.commit("setConnected"); this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null); this.$store.commit("setErrorMessage", null);
} catch (error) { } catch (error) {

View file

@ -15,6 +15,7 @@
:id="item.id + '-tab-icon'" :id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'" :key="item.id + '-tab-icon'"
:tab-i-d="item.id" :tab-i-d="item.id"
:title="item.title"
:require-connection="true" :require-connection="true"
:current-tab="currentTab" :current-tab="currentTab"
:class="item.class" :class="item.class"
@ -42,6 +43,7 @@
:id="item.id + '-tab-icon'" :id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'" :key="item.id + '-tab-icon'"
:tab-i-d="item.id" :tab-i-d="item.id"
:title="item.title"
:require-connection="true" :require-connection="true"
:current-tab="currentTab" :current-tab="currentTab"
:class="item.class" :class="item.class"
@ -81,19 +83,18 @@ import tabIcon from "./genericComponents/tabIcon";
import tabContent from "./genericComponents/tabContent"; import tabContent from "./genericComponents/tabContent";
// Import new content components // Import new content components
import controlContent from "./tabContentComponents/controlContent.vue";
import slideScanContent from "./tabContentComponents/slideScanContent.vue";
import backgroundDetectContent from "./tabContentComponents/backgroundDetectContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue";
import settingsContent from "./tabContentComponents/settingsContent.vue";
import aboutContent from "./tabContentComponents/aboutContent.vue"; import aboutContent from "./tabContentComponents/aboutContent.vue";
import backgroundDetectContent from "./tabContentComponents/backgroundDetectContent.vue";
import controlContent from "./tabContentComponents/controlContent.vue";
import loggingContent from "./tabContentComponents/loggingContent.vue"; import loggingContent from "./tabContentComponents/loggingContent.vue";
import powerContent from "./tabContentComponents/powerContent.vue"; import powerContent from "./tabContentComponents/powerContent.vue";
import scanListContent from "./tabContentComponents/scanListContent.vue";
import settingsContent from "./tabContentComponents/settingsContent.vue";
import slideScanContent from "./tabContentComponents/slideScanContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue";
// Import modal components for device initialisation // Import modal components for device initialisation
import calibrationWizard from "./modalComponents/calibrationWizard.vue"; import calibrationWizard from "./modalComponents/calibrationWizard.vue";
import TabIcon from "./genericComponents/tabIcon.vue";
import ScanListContent from "./tabContentComponents/scanListContent.vue";
// Export main app // Export main app
export default { export default {
@ -102,15 +103,7 @@ export default {
components: { components: {
tabIcon, tabIcon,
tabContent, tabContent,
controlContent,
slideScanContent,
viewContent,
settingsContent,
calibrationWizard, calibrationWizard,
aboutContent,
loggingContent,
TabIcon,
powerContent,
}, },
data: function () { data: function () {
return { return {
@ -118,26 +111,69 @@ export default {
bottomTabs: [ bottomTabs: [
{ {
id: "settings", id: "settings",
title: "Settings",
icon: "settings", icon: "settings",
component: settingsContent, component: settingsContent,
class: "uk-margin-auto-top", class: "uk-margin-auto-top",
}, },
{ {
id: "logging", id: "logging",
title: "Logging",
icon: "assignment_late", icon: "assignment_late",
component: loggingContent, component: loggingContent,
}, },
{ {
id: "about", id: "about",
title: "About",
icon: "info", icon: "info",
component: aboutContent, component: aboutContent,
}, },
{ {
id: "power", id: "power",
title: "Power",
icon: "power_settings_new", icon: "power_settings_new",
component: powerContent, component: powerContent,
}, },
], ],
coreTopTabs: [
{
id: "view",
title: "View",
icon: "visibility",
component: viewContent,
requiredThings: [],
},
{
id: "control",
title: "Control",
icon: "gamepad",
component: controlContent,
requiredThings: [],
},
{
id: "background-detect",
title: "Background Detect",
icon: "background_replace",
component: backgroundDetectContent,
// While stage isn't needed; automatic background detect has little function
// for a manual microscope.
requiredThings: ["stage"],
},
{
id: "slide-scan",
title: "Slide Scan",
icon: "settings_overscan",
component: slideScanContent,
requiredThings: ["smart_scan"],
},
{
id: "scan-list",
title: "Scan List",
icon: "photo_library",
component: scanListContent,
requiredThings: ["smart_scan"],
},
],
}; };
}, },
@ -154,37 +190,12 @@ export default {
}, },
topTabs: function () { topTabs: function () {
let tabs = [ // Filter core top tabs based on available Things. Once Things can specify a
{ // custom tab those will need to be added here
id: "view", return this.coreTopTabs.filter((tab) => {
icon: "visibility", if (!tab.requiredThings || tab.requiredThings.length === 0) return true;
component: viewContent, return tab.requiredThings.every((thing) => this.thingAvailable(thing));
}, });
{
id: "control",
icon: "gamepad",
component: controlContent,
},
{
id: "background detect",
icon: "background_replace",
component: backgroundDetectContent,
},
{
id: "slide scan",
icon: "settings_overscan",
component: slideScanContent,
},
{
id: "scan list",
icon: "photo_library",
component: ScanListContent,
},
];
if (!this.$store.state.galleryEnabled) {
tabs = tabs.filter((tab) => tab.id != "gallery");
}
return tabs;
}, },
allTabs() { allTabs() {
return [...this.topTabs, ...this.bottomTabs]; return [...this.topTabs, ...this.bottomTabs];

View file

@ -209,6 +209,8 @@ export default {
// A global signal listener to perform the action // A global signal listener to perform the action
if (this.submitOnEvent) { if (this.submitOnEvent) {
this.$root.$on(this.submitOnEvent, () => { this.$root.$on(this.submitOnEvent, () => {
if (this.isDisabled) return;
// Bootstrap task if button is not disabled.
this.bootstrapTask(); this.bootstrapTask();
}); });
} }

View file

@ -78,13 +78,11 @@ export default {
const calibrateableThings = Object.keys(this.availableCalibrationTasks); const calibrateableThings = Object.keys(this.availableCalibrationTasks);
for (const name of calibrateableThings) { for (const name of calibrateableThings) {
try { if (this.thingPropertyAvailable(name, "calibration_required")) {
const thingNeedsCal = await this.readThingProperty(name, "calibration_required"); const thingNeedsCal = await this.readThingProperty(name, "calibration_required");
if (thingNeedsCal) { if (thingNeedsCal) {
needsCalibration.push(name); needsCalibration.push(name);
} }
} catch (e) {
console.error(`${name}: missing calibration_required property`, e);
} }
} }

View file

@ -13,6 +13,7 @@
{{ $store.state.origin }} {{ $store.state.origin }}
</div> </div>
<action-button <action-button
v-if="stageType"
thing="stage" thing="stage"
action="flash_led" action="flash_led"
submit-label="Flash Illumination" submit-label="Flash Illumination"
@ -34,18 +35,16 @@
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
<b>Camera:</b> <b>Camera:</b>
<br /> <br />
<div v-if="'camera' in things"> <div>
{{ things.camera.title }} {{ cameraType }}
</div> </div>
<div v-else class="uk-text-danger"><b>No camera configured</b></div>
</div> </div>
<div> <div v-if="stageType">
<b>Stage:</b> <b>Stage:</b>
<br /> <br />
<div v-if="'stage' in things"> <div>
{{ things.stage.title }} {{ stageType }}
</div> </div>
<div v-else class="uk-text-danger"><b>No stage configured</b></div>
</div> </div>
<hr /> <hr />
@ -71,8 +70,12 @@ export default {
}, },
computed: { computed: {
things: function () { cameraType() {
return this.$store.getters["wot/thingDescriptions"]; // No need to check as the microscope won't start up without a camera defined
return this.thingDescription("camera").title;
},
stageType() {
return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined;
}, },
}, },

View file

@ -0,0 +1,46 @@
<template>
<div>
<p>Autofocus</p>
<div class="uk-margin">
<action-button
thing="autofocus"
action="fast_autofocus"
:submit-data="{ dz: 2000 }"
:submit-label="'Autofocus'"
:button-primary="true"
:submit-on-event="'globalFastAutofocusEvent'"
:is-disabled="isAutofocusing"
@taskStarted="onAutofocus"
@finished="afterAutofocus"
@error="modalError"
/>
</div>
</div>
</template>
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
export default {
name: "AutofocusControl",
components: {
ActionButton,
},
data: function () {
return {
isAutofocusing: false,
};
},
methods: {
onAutofocus() {
this.isAutofocusing = true;
},
afterAutofocus() {
this.isAutofocusing = false;
this.$root.$emit("globalUpdatePositionEvent");
},
},
};
</script>

View file

@ -1,145 +1,30 @@
<template> <template>
<div id="paneControl" class="uk-padding-small"> <div id="paneControl" class="uk-padding-small">
<div v-if="setPosition"> <positionControl v-if="stageAvailable" />
<ul uk-accordion="multiple: true"> <autofocusControl v-if="autofocusAvailable" />
<li> <p>Image Capture</p>
<a class="uk-accordion-title" href="#">Configure</a>
<div class="uk-accordion-content">
<b>Keyboard Step Size</b>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input v-model="stepSize.x" class="uk-input uk-form-small" type="number" />
</div>
<label class="uk-margin-small-right"
><input v-model="invert.x" class="uk-checkbox" type="checkbox" /> Invert x</label
>
</div>
<div> <div class="uk-margin">
<label class="uk-form-label" for="form-stacked-text">y</label> <action-button
<div class="uk-form-controls"> thing="camera"
<input v-model="stepSize.y" class="uk-input uk-form-small" type="number" /> action="capture_jpeg"
</div> :submit-data="{ stream_name: 'main' }"
<label class="uk-margin-small-right" :submit-label="'Low Resolution'"
><input v-model="invert.y" class="uk-checkbox" type="checkbox" /> Invert y</label @response="handleCaptureResponse"
> @error="modalError"
</div> />
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input v-model="stepSize.z" class="uk-input uk-form-small" type="number" />
</div>
</div>
</div>
<br />
<action-button
thing="stage"
action="set_zero_position"
submit-label="Zero Coordinates"
:can-terminate="false"
@finished="updatePosition"
@error="modalError"
/>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Position</a>
<div class="uk-accordion-content">
<form>
<!-- Text boxes to set and view position -->
<div class="input-and-buttons-container">
<input
v-for="(_v, key) in setPosition"
:key="`setPosition_${key}`"
v-model="setPosition[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@keyup.enter="startMoveTask"
/>
<sync-property-button @click="updatePosition" />
</div>
<p>
<action-button
ref="moveButton"
thing="stage"
action="move_absolute"
:submit-data="setPosition"
:submit-label="'Move'"
:can-terminate="true"
:poll-interval="0.05"
@taskStarted="moveLock = true"
@finished="
updatePosition();
moveLock = false;
"
@error="modalError"
/>
</p>
</form>
</div>
</li>
<!--Show autofocus if default plugin is enabled-->
<li class="uk-open">
<a class="uk-accordion-title" href="#">Autofocus</a>
<div class="uk-accordion-content">
<div class="uk-grid-small uk-child-width-expand" uk-grid>
<div v-show="!isAutofocusing || isAutofocusing == 1">
<action-button
thing="autofocus"
action="fast_autofocus"
:submit-data="{ dz: 2000 }"
:submit-label="'Autofocus'"
:button-primary="true"
:submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="isAutofocusing = 1"
@finished="
updatePosition();
isAutofocusing = 0;
"
@error="modalError"
/>
</div>
</div>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Image Capture</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<action-button
thing="camera"
action="capture_jpeg"
:submit-data="{ stream_name: 'main' }"
:submit-label="'Low Resolution'"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
/>
</div>
<div class="uk-margin">
<action-button
thing="camera"
action="capture_jpeg"
:submit-data="{ stream_name: 'full' }"
submit-label="Full Resolution"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
/>
</div>
</div>
</li>
</ul>
</div> </div>
<div v-else class="uk-text-warning">
<p>Stage positioning is disabled since no stage is connected.</p> <div class="uk-margin">
<p>Please check all data and power connections to your motor controller board.</p> <action-button
thing="camera"
action="capture_jpeg"
:submit-data="{ stream_name: 'full' }"
submit-label="Full Resolution"
:submit-on-event="'globalCaptureEvent'"
@response="handleCaptureResponse"
@error="modalError"
/>
</div> </div>
</div> </div>
</template> </template>
@ -147,151 +32,28 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue"; import positionControl from "./positionControl.vue";
import autofocusControl from "./autofocusControl.vue";
// Export main app
export default { export default {
name: "PaneControl", name: "PaneControl",
components: { components: {
ActionButton, ActionButton,
syncPropertyButton, positionControl,
}, autofocusControl,
data: function () {
return {
setPosition: null,
isAutofocusing: 0,
moveLock: false,
};
}, },
computed: { computed: {
// Note that as stepSize and invert are set based on internals we can use stageAvailable() {
// get() and set() to interact with the store. Instead use a deep watcher to return this.thingAvailable("stage");
// update the store (see ``watch:`` below)
stepSize() {
return this.$store.state.navigationStepSize;
}, },
invert() { autofocusAvailable() {
return this.$store.state.navigationInvert; return this.thingAvailable("autofocus");
}, },
baseUri: function () {
return this.$store.getters.baseUri;
},
positionStatusUri: function () {
return `${this.baseUri}/stage/position`;
},
moveInImageCoordinatesUri: function () {
return this.thingActionUrl("camera_stage_mapping", "move_in_image_coordinates");
},
},
watch: {
stepSize: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationStepSize", newVal);
},
},
invert: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationInvert", newVal);
},
},
},
async mounted() {
let self = this;
// A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", self.move);
// A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
this.$root.$emit(
"globalMoveEvent",
x_steps * this.stepSize.x * (this.invert.x ? -1 : 1),
y_steps * this.stepSize.y * (this.invert.y ? -1 : 1),
z_steps * this.stepSize.z * (this.invert.z ? -1 : 1),
false,
);
});
// Update the current position in text boxes
await this.updatePosition();
},
beforeDestroy() {
// Remove global signal listener to perform a move action
this.$root.$off("globalMoveEvent");
this.$root.$off("globalMoveInImageCoordinatesEvent");
this.$root.$off("globalMoveStepEvent");
}, },
methods: { methods: {
timeout(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
async move(x, y, z, absolute) {
// Move the stage, by updating the controls and starting a move task
// This is equivalent to clicking the "move" button.
if (this.moveLock) return; // Discard move requests if we're already moving
// NB moveLock is just boolean flag - it's not as safe as a "proper" lock.
this.moveLock = true; // This will also be set by the task submitter, but
// setting it here avoids multiple moves being requested simultaneously.
if (absolute) {
this.setPosition = { x: x, y: y, z: z };
} else {
await this.updatePosition();
this.setPosition = {
x: this.setPosition.x + x,
y: this.setPosition.y + y,
z: this.setPosition.z + z,
};
}
await this.timeout(1); // Wait for Vue to update the position
await this.startMoveTask();
},
async startMoveTask() {
await this.$refs.moveButton.startTask();
},
moveInImageCoordinatesRequest: function (x, y) {
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
if (!this.moveInImageCoordinatesUri) {
this.modalError(
"Moving in image coordinates is not supported - you will need to upgrade your microscope's software.",
);
}
// Send move request
axios
.post(this.moveInImageCoordinatesUri, {
x: x, // NB the coordinates are numpy/PIL style, meaning X and Y are swapped.
y: y,
})
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch((error) => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
}
},
async updatePosition() {
this.setPosition = await this.readThingProperty("stage", "position");
},
handleCaptureResponse: async function (response) { handleCaptureResponse: async function (response) {
// Retrieve the captured image and save it // Retrieve the captured image and save it
let imageUri = response.output.href; let imageUri = response.output.href;
@ -312,29 +74,3 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin-left: 5px;
margin-right: 5px;
width: 3em;
/* Stop Firefox showing input spinners, other
browsers set with block below */
-moz-appearance: textfield;
}
/* Chrome, Safari, Edge, Opera */
.numeric-setting-line-input::-webkit-outer-spin-button,
.numeric-setting-line-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View file

@ -0,0 +1,198 @@
<!-- This is the main position component for the control tab.
Do not reuse this component until we remove global listeners for move.
This component shows themotor positions in a closed accordion along with the move
and zero position buttons. It also includes the d-pad.
-->
<template>
<div>
<ul uk-accordion="multiple: true">
<li class="uk-closed">
<a class="uk-accordion-title" href="#">Position</a>
<div class="uk-accordion-content">
<form>
<!-- Text boxes to set and view position -->
<div class="input-and-buttons-container">
<input
v-for="(_v, key) in setPosition"
:key="`setPosition_${key}`"
v-model="setPosition[key]"
class="uk-form-small numeric-setting-line-input"
type="number"
@keyup.enter="startMoveTask"
/>
<sync-property-button @click="updatePosition" />
</div>
<p>
<action-button
ref="moveButton"
thing="stage"
action="move_absolute"
:submit-data="setPosition"
:submit-label="'Move'"
:can-terminate="true"
:poll-interval="0.05"
@finished="moveComplete"
@error="modalError"
/>
</p>
</form>
<action-button
thing="stage"
action="set_zero_position"
submit-label="Zero Coordinates"
:can-terminate="false"
@finished="updatePosition"
@error="modalError"
/>
<div class="uk-flex uk-flex-center uk-margin">
<hr class="uk-divider-small" />
</div>
</div>
</li>
</ul>
<stageControlButtons />
</div>
</template>
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
import syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue";
import stageControlButtons from "./stageControlButtons.vue";
export default {
name: "PaneControl",
components: {
ActionButton,
syncPropertyButton,
stageControlButtons,
},
data: function () {
return {
setPosition: null,
moveLock: false,
};
},
computed: {
positionStatusUri: function () {
return this.thingActionUrl("stage", "position");
},
},
async mounted() {
let self = this;
// A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", self.move);
this.$root.$on("globalUpdatePositionEvent", self.updatePosition);
// A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
const navigationStepSize = this.$store.state.navigationStepSize;
const navigationInvert = this.$store.state.navigationInvert;
const x = x_steps * navigationStepSize.x * (navigationInvert.x ? -1 : 1);
const y = y_steps * navigationStepSize.y * (navigationInvert.y ? -1 : 1);
const z = z_steps * navigationStepSize.z * (navigationInvert.z ? -1 : 1);
this.$root.$emit("globalMoveEvent", x, y, z, false);
});
// Update the current position in text boxes
await this.updatePosition();
},
beforeDestroy() {
// Remove global signal listener to perform a move action
this.$root.$off("globalMoveEvent");
this.$root.$off("globalMoveInImageCoordinatesEvent");
this.$root.$off("globalMoveStepEvent");
this.$root.$off("globalUpdatePositionEvent");
},
methods: {
timeout(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
async move(x, y, z, absolute) {
// Move the stage, by updating the controls and starting a move task
// This is equivalent to clicking the "move" button.
if (this.moveLock) return; // Discard move requests if we're already moving
// NB moveLock is just boolean flag - it's not as safe as a "proper" lock.
this.moveLock = true; // This will also be set by the task submitter, but
// setting it here avoids multiple moves being requested simultaneously.
if (absolute) {
this.setPosition = { x: x, y: y, z: z };
} else {
await this.updatePosition();
this.setPosition = {
x: this.setPosition.x + x,
y: this.setPosition.y + y,
z: this.setPosition.z + z,
};
}
await this.timeout(1); // Wait for Vue to update the position
await this.startMoveTask();
},
async startMoveTask() {
this.moveLock = true;
await this.$refs.moveButton.startTask();
},
moveComplete() {
this.updatePosition();
this.moveLock = false;
},
async moveInImageCoordinatesRequest(x, y) {
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
const response = await this.invokeAction(
"camera_stage_mapping",
"move_in_image_coordinates",
{
x: x,
y: y,
},
);
this.pollUntilComplete(
response.data.href,
null, // Nothing to do while ongoing
this.moveComplete, // Call move complete once done.
200,
);
}
},
async updatePosition() {
this.setPosition = await this.readThingProperty("stage", "position");
},
},
};
</script>
<style scoped>
.input-and-buttons-container {
display: flex;
flex-flow: column wrap;
justify-content: flex-start;
align-content: stretch;
align-items: center;
width: 100%;
}
.numeric-setting-line-input {
flex-grow: 1;
margin: 5px 0px;
width: 5em;
/* Stop Firefox showing input spinners, other
browsers set with block below */
-moz-appearance: textfield;
}
/* Chrome, Safari, Edge, Opera */
.numeric-setting-line-input::-webkit-outer-spin-button,
.numeric-setting-line-input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="uk-flex uk-flex-center uk-flex-middle uk-margin">
<div class="dpad-grid">
<button id="up-button" class="uk-button uk-button-primary dpad-btn" @click="move(0, 1, 0)">
<span class="material-symbols-outlined sync-icon"> arrow_upward </span>
</button>
<button id="left-button" class="uk-button uk-button-primary dpad-btn" @click="move(-1, 0, 0)">
<span class="material-symbols-outlined sync-icon"> arrow_back </span>
</button>
<button id="right-button" class="uk-button uk-button-primary dpad-btn" @click="move(1, 0, 0)">
<span class="material-symbols-outlined sync-icon"> arrow_forward </span>
</button>
<button id="down-button" class="uk-button uk-button-primary dpad-btn" @click="move(0, -1, 0)">
<span class="material-symbols-outlined sync-icon"> arrow_downward </span>
</button>
<button
id="focus-out-button"
class="uk-button uk-button-primary dpad-btn"
@click="move(0, 0, -1)"
>
<span class="material-symbols-outlined sync-icon"> remove </span>
</button>
<button
id="focus-in-button"
class="uk-button uk-button-primary dpad-btn"
@click="move(0, 0, 1)"
>
<span class="material-symbols-outlined sync-icon"> add </span>
</button>
</div>
</div>
</template>
<script>
export default {
name: "StageControlButtons",
methods: {
move(x, y, z) {
this.$root.$emit("globalMoveStepEvent", x, y, z);
},
},
};
</script>
<style scoped>
.dpad-grid {
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: 40px 40px 40px 20px 40px;
gap: 1px;
justify-content: center;
align-items: center;
}
/* Place buttons within grid */
.dpad-grid #up-button {
grid-column: 2;
grid-row: 1;
}
.dpad-grid #left-button {
grid-column: 1;
grid-row: 2;
}
.dpad-grid #right-button {
grid-column: 3;
grid-row: 2;
}
.dpad-grid #down-button {
grid-column: 2;
grid-row: 3;
}
.dpad-grid #focus-out-button {
grid-column: 1;
grid-row: 5;
}
.dpad-grid #focus-in-button {
grid-column: 3;
grid-row: 5;
}
.dpad-btn {
width: 40px;
height: 40px;
justify-content: center;
align-items: center;
display: flex;
}
</style>

View file

@ -23,3 +23,9 @@ export default {
}, },
}; };
</script> </script>
<style scoped>
.control-component {
width: 160px;
}
</style>

View file

@ -10,7 +10,7 @@
</p> </p>
<div class="buttons-container"> <div class="buttons-container">
<button <button
v-show="'shutdown' in things.system.actions" v-show="shutdownAvailable"
class="uk-button uk-button-primary uk-width-1-3 shutdown-button" class="uk-button uk-button-primary uk-width-1-3 shutdown-button"
@click="systemRequest('shutdown')" @click="systemRequest('shutdown')"
> >
@ -42,8 +42,8 @@ export default {
}, },
computed: { computed: {
things: function () { shutdownAvailable() {
return this.$store.getters["wot/thingDescriptions"]; return this.thingActionAvailable("system", "shutdown");
}, },
}, },

View file

@ -119,12 +119,7 @@ export default {
computed: { computed: {
scansUri() { scansUri() {
return this.$store.getters["wot/thingPropertyUrl"]( return this.thingPropertyUrl("smart_scan", "scans");
"smart_scan",
"scans",
"readproperty",
true,
);
}, },
scansEmpty() { scansEmpty() {
return this.scans.length == 0; return this.scans.length == 0;

View file

@ -86,10 +86,10 @@ export default {
computed: { computed: {
actions() { actions() {
return this.$store.getters["wot/thingDescription"]("camera_stage_mapping").actions; return this.thingDescription("camera_stage_mapping").actions;
}, },
properties() { properties() {
return this.$store.getters["wot/thingDescription"]("camera_stage_mapping").properties; return this.thingDescription("camera_stage_mapping").properties;
}, },
}, },

View file

@ -52,7 +52,7 @@ export default {
computed: { computed: {
actions() { actions() {
return this.$store.getters["wot/thingDescription"]("camera").actions; return this.thingDescription("camera").actions;
}, },
}, },

View file

@ -0,0 +1,21 @@
<template>
<div>
<appSettings />
<streamSettings />
</div>
</template>
<script>
import appSettings from "./displaySetttingsComponents/appSettings.vue";
import streamSettings from "./displaySetttingsComponents/streamSettings.vue";
// Export main app
export default {
name: "DisplaySettings",
components: {
streamSettings,
appSettings,
},
};
</script>

View file

@ -0,0 +1,74 @@
<template>
<div class="uk-width-large">
<p><b>Single Move Step Size</b></p>
<p>
This sets the size (and direction) of movements made using the navigation buttons in the
control tab or using the keyboard (arrow keys, page up/down).
</p>
<p>These settings do not affect the operation of other actions your microscope performs.</p>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input v-model="stepSize.x" class="uk-input uk-form-small" type="number" />
</div>
<label class="uk-margin-small-right">
<input v-model="invert.x" class="uk-checkbox" type="checkbox" />
Invert x
</label>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input v-model="stepSize.y" class="uk-input uk-form-small" type="number" />
</div>
<label class="uk-margin-small-right">
<input v-model="invert.y" class="uk-checkbox" type="checkbox" />
Invert y
</label>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input v-model="stepSize.z" class="uk-input uk-form-small" type="number" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "StageControlSettings",
computed: {
// Note that as stepSize and invert are mutated (i.e. we change stepSize.x not stepSize)
// rather than directly set we cannot use get() and set() computed to interact with the
// store as changed won't be detected by set(). Instead use a deep watcher to
// update the store (see ``watch:`` below)
stepSize() {
return this.$store.state.navigationStepSize;
},
invert() {
return this.$store.state.navigationInvert;
},
},
watch: {
stepSize: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationStepSize", newVal);
},
},
invert: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationInvert", newVal);
},
},
},
};
</script>

View file

@ -5,17 +5,17 @@
<div class="settings-nav"> <div class="settings-nav">
<ul class="uk-nav uk-nav-default"> <ul class="uk-nav uk-nav-default">
<li class="uk-nav-header">Application Settings</li> <li class="uk-nav-header">Application Settings</li>
<li> <li v-for="item in appTabs" :key="'setting-' + item.id + '-tab-content'">
<tabIcon <tabIcon
id="settings-display-icon" :id="'setting-' + item.id + '-tab-content'"
tab-i-d="display" :tab-i-d="item.id"
:show-title="false" :show-title="false"
:show-tooltip="false" :show-tooltip="false"
:require-connection="false" :require-connection="item.requireConnection"
:current-tab="currentTab" :current-tab="currentTab"
@set-tab="setTab" @set-tab="setTab"
> >
Display {{ item.title }}
</tabIcon> </tabIcon>
</li> </li>
<li class="uk-nav-header">Microscope Settings</li> <li class="uk-nav-header">Microscope Settings</li>
@ -27,81 +27,41 @@
> >
Launch Calibration Wizard Launch Calibration Wizard
</button> </button>
<li> <li v-for="item in calibrationTabs" :key="'setting-' + item.id + '-tab-icon'">
<tabIcon <tabIcon
id="settings-camera-icon" :id="'setting-' + item.id + '-tab-icon'"
tab-i-d="camera" :tab-i-d="item.id"
:show-title="false" :show-title="false"
:show-tooltip="false" :show-tooltip="false"
:require-connection="true" :require-connection="item.requireConnection"
:current-tab="currentTab" :current-tab="currentTab"
@set-tab="setTab" @set-tab="setTab"
> >
Camera {{ item.title }}
</tabIcon>
</li>
<li>
<tabIcon
id="settings-stage-icon"
tab-i-d="stage"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Stage
</tabIcon>
</li>
<li>
<tabIcon
id="settings-mapping-icon"
tab-i-d="mapping"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Camera to Stage Mapping
</tabIcon> </tabIcon>
</li> </li>
</ul> </ul>
</div> </div>
<div class="view-component uk-width-expand uk-padding-small"> <div class="view-component uk-width-expand uk-padding-small">
<tabContent tab-i-d="display" :require-connection="false" :current-tab="currentTab"> <tabContent
<div class="settings-pane uk-padding-small"> v-for="item in allTabs"
<appSettings /> :id="'setting-' + item.id + '-tab-content'"
<streamSettings /> :key="'setting-' + item.id + '-tab-content'"
</div> :tab-i-d="item.id"
</tabContent> :require-connection="item.requireConnection"
:current-tab="currentTab"
<tabContent tab-i-d="camera" :require-connection="true" :current-tab="currentTab"> >
<div class="settings-pane uk-padding-small"> <component :is="item.component"></component>
<cameraSettings />
</div>
</tabContent>
<tabContent tab-i-d="stage" :require-connection="true" :current-tab="currentTab">
<div class="settings-pane uk-padding-small">
<stageSettings />
</div>
</tabContent>
<tabContent tab-i-d="mapping" :require-connection="true" :current-tab="currentTab">
<div class="settings-pane uk-padding-small">
<CSMSettings />
</div>
</tabContent> </tabContent>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import streamSettings from "./settingsComponents/streamSettings.vue"; import displaySettings from "./settingsComponents/displaySettings.vue";
import stageControlSettings from "./settingsComponents/stageControlSettings.vue";
import calibrationWizard from "../modalComponents/calibrationWizard.vue"; import calibrationWizard from "../modalComponents/calibrationWizard.vue";
import cameraSettings from "./settingsComponents/cameraSettings.vue"; import cameraSettings from "./settingsComponents/cameraSettings.vue";
import appSettings from "./settingsComponents/appSettings.vue";
import CSMSettings from "./settingsComponents/CSMSettings.vue"; import CSMSettings from "./settingsComponents/CSMSettings.vue";
import stageSettings from "./settingsComponents/stageSettings.vue"; import stageSettings from "./settingsComponents/stageSettings.vue";
// Import generic components // Import generic components
@ -113,11 +73,6 @@ export default {
name: "SettingsContent", name: "SettingsContent",
components: { components: {
streamSettings,
cameraSettings,
stageSettings,
CSMSettings,
appSettings,
tabIcon, tabIcon,
tabContent, tabContent,
calibrationWizard, calibrationWizard,
@ -127,9 +82,67 @@ export default {
return { return {
selected: "display", selected: "display",
currentTab: "display", currentTab: "display",
coreAppTabs: [
{
id: "display",
title: "Display",
requireConnection: false,
component: displaySettings,
requiredThings: [],
},
{
id: "stage-control",
title: "Stage Control Preferences",
requireConnection: false,
component: stageControlSettings,
requiredThings: ["stage"],
},
],
coreCalibrationTabs: [
{
id: "camera",
title: "Camera",
requireConnection: true,
component: cameraSettings,
requiredThings: [],
},
{
id: "stage",
title: "Stage",
requireConnection: true,
component: stageSettings,
requiredThings: ["stage"],
},
{
id: "mapping",
title: "Camera to Stage Mapping",
requireConnection: true,
component: CSMSettings,
requiredThings: ["camera_stage_mapping"],
},
],
}; };
}, },
computed: {
allTabs() {
return [...this.appTabs, ...this.calibrationTabs];
},
appTabs() {
// Filter core top tabs based on available Things.
return this.coreAppTabs.filter((tab) => {
if (!tab.requiredThings || tab.requiredThings.length === 0) return true;
return tab.requiredThings.every((thing) => this.thingAvailable(thing));
});
},
calibrationTabs() {
// Filter core top tabs based on available Things.
return this.coreCalibrationTabs.filter((tab) => {
if (!tab.requiredThings || tab.requiredThings.length === 0) return true;
return tab.requiredThings.every((thing) => this.thingAvailable(thing));
});
},
},
methods: { methods: {
setTab: function (event, tab) { setTab: function (event, tab) {
if (!(this.currentTab == tab)) { if (!(this.currentTab == tab)) {

View file

@ -1,7 +1,5 @@
<template> <template>
<div v-if="!backendOK" class="uk-alert-danger">No scan back-end found.</div> <div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<!-- Grid managing tab content -->
<div v-else uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small"> <div class="control-component uk-padding-small">
<div v-show="!scanning" class="uk-padding-small"> <div v-show="!scanning" class="uk-padding-small">
<ul uk-accordion="multiple: true"> <ul uk-accordion="multiple: true">
@ -183,9 +181,6 @@ export default {
}, },
computed: { computed: {
backendOK() {
return this.thingAvailable("smart_scan");
},
cancellable() { cancellable() {
return (this.taskStatus == "running") | (this.taskStatus == "pending"); return (this.taskStatus == "running") | (this.taskStatus == "pending");
}, },

View file

@ -1,14 +1,15 @@
import Vue from "vue"; import Vue from "vue";
import App from "./App.vue"; import App from "./App.vue";
import store from "./store"; import store from "./store";
import axios from "axios";
import UIkit from "uikit"; import UIkit from "uikit";
import VueObserveVisibility from "vue-observe-visibility"; import VueObserveVisibility from "vue-observe-visibility";
// Import MD icons // Import MD icons
import "material-symbols/outlined.css"; import "material-symbols/outlined.css";
import version from "./version.js";
import queryMixin from "@/mixins/labThingsMixins.js";
import modalMixin from "@/mixins/modalMixins.js";
// UIKit overrides // UIKit overrides
UIkit.mixin( UIkit.mixin(
@ -25,176 +26,8 @@ Vue.use(VueObserveVisibility);
Vue.config.productionTip = false; Vue.config.productionTip = false;
Vue.mixin({ Vue.mixin(queryMixin);
methods: { Vue.mixin(modalMixin);
app_version() {
return version;
},
thingDescription(thing) {
return this.$store.getters["wot/thingDescription"](thing);
},
thingAvailable(thing) {
return this.$store.getters["wot/thingAvailable"](thing);
},
async readThingProperty(thing, property, silence_errors = false) {
let url = this.$store.getters["wot/thingPropertyUrl"](thing, property, "readproperty", false);
try {
let response = await axios.get(url);
return response.data;
} catch (error) {
if (!silence_errors) this.modalError(error);
return undefined;
}
},
async writeThingProperty(thing, property, value) {
let url = this.$store.getters["wot/thingPropertyUrl"](
thing,
property,
"writeproperty",
false,
);
// `false` fails because axios somehow eats it!
// Other values should not be stringified or pydantic
// can't parse them.
if ((value === false) | (value === true)) {
value = JSON.stringify(value);
}
await axios.put(url, value);
},
async invokeAction(thing, action, data) {
let url = this.$store.getters["wot/thingActionUrl"](thing, action, "invokeaction", false);
try {
let response = await axios.post(url, data);
return response;
} catch (error) {
this.modalError(error);
return undefined;
}
},
thingActionUrl(thing, action, allow_missing = false) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
action,
"invokeaction",
allow_missing,
);
return url;
},
modalConfirm: function (modalText) {
var context = this;
// Stop GPU preview to show modal
context.$root.$emit("globalTogglePreview", false);
// force OK to be capitalised
UIkit.modal.i18n = { ok: "OK", cancel: "Cancel" };
var showModal = function (resolve, reject) {
UIkit.modal
.confirm(modalText, { stack: true })
.then(
function () {
resolve();
},
function () {
reject();
},
)
.finally(function () {
// Re-enable the GPU preview, if it was active before the modal
if (context.$store.state.autoGpuPreview) {
context.$root.$emit("globalTogglePreview", true);
}
context.$root.$emit("modalClosed");
});
};
return new Promise(showModal);
},
modalNotify: function (message, status = "success") {
UIkit.notification({
message: message,
status: status,
});
},
modalDialog: function (title, message) {
UIkit.modal.dialog(
`
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">${title}</h2>
</div>
<div class="uk-modal-body">
<p>${message}</p>
</div>
`,
{ stack: true },
);
},
modalError: function (error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
UIkit.notification({
message: `${errormsg}`,
status: "danger",
});
},
getErrorMessage: function (error) {
// Format the error.
let data = this.getErrorData(error);
// Get error data may get an object. Handle edge cases and if not try to use
// JSON to get the best string. This stops "objectObject" showing as an error.
if (data === null) return "null";
if (data === undefined) return "undefined";
if (typeof data === "string") return data;
try {
return JSON.stringify(data, null, 2);
} catch (err) {
return String(data);
}
},
getErrorData: function (error) {
// If a response was obtained, extract the most specific message
if (error.response) {
// If the response is a nicely formatted JSON response from the server
if (error.response.data.message) {
return error.response.data.message;
}
if (error.response.data.detail) {
try {
return error.response.data.detail[0].msg;
} catch (err) {
return error.response.data.detail;
}
}
// If the response is just some generic error response
if (error.response.data) {
return error.response.data;
}
return error.response;
}
// If we have an error object with a message, use that
if (error.message) return error.message;
// At this point just formatting the whole error object is the best we can do.
return error;
},
showModalElement: function (element) {
UIkit.modal(element).show();
},
hideModalElement: function (element) {
UIkit.modal(element).hide();
},
toggleModalElement: function (element) {
UIkit.modal(element).toggle();
},
},
});
new Vue({ new Vue({
store, store,

View file

@ -0,0 +1,109 @@
/**
* @fileoverview
* Global mixin providing access to the LabThings (WoT) API.
*
* This mixin is registered globally in `main.js` using. Do not
* manually import it in components.
*/
import axios from "axios";
export default {
data() {
return {
pollTimers: {},
};
},
methods: {
thingDescription(thing) {
return this.$store.getters["wot/thingDescription"](thing);
},
thingList() {
return this.$store.getters["wot/thingList"];
},
thingAvailable(thing) {
return this.$store.getters["wot/thingAvailable"](thing);
},
thingPropertyUrl(thing, property, allowUndefined = false) {
return this.$store.getters["wot/thingPropertyUrl"](
thing,
property,
"readproperty",
allowUndefined,
);
},
thingActionAvailable(thing, action) {
return this.$store.getters["wot/thingAffordanceAvailable"](thing, "actions", action);
},
thingPropertyAvailable(thing, property) {
return this.$store.getters["wot/thingAffordanceAvailable"](thing, "properties", property);
},
async readThingProperty(thing, property, silenceErrors = false) {
let url = this.$store.getters["wot/thingPropertyUrl"](thing, property, "readproperty", false);
try {
let response = await axios.get(url);
return response.data;
} catch (error) {
if (!silenceErrors) this.modalError(error);
return undefined;
}
},
async writeThingProperty(thing, property, value) {
let url = this.$store.getters["wot/thingPropertyUrl"](
thing,
property,
"writeproperty",
false,
);
// `false` fails because axios somehow eats it!
// Other values should not be stringified or pydantic
// can't parse them.
if ((value === false) | (value === true)) {
value = JSON.stringify(value);
}
await axios.put(url, value);
},
async invokeAction(thing, action, data) {
let url = this.$store.getters["wot/thingActionUrl"](thing, action, "invokeaction", false);
try {
let response = await axios.post(url, data);
return response;
} catch (error) {
this.modalError(error);
return undefined;
}
},
async pollUntilComplete(taskUrl, ongoingMethod, finalMethod, interval = 500) {
try {
const response = await axios.get(taskUrl, { baseURL: this.$store.getters.baseUri });
const result = response.data.status;
if ((result == "running") | (result == "pending")) {
ongoingMethod?.(response);
this.pollTimers[taskUrl] = setTimeout(() => {
this.pollUntilComplete(taskUrl, ongoingMethod, finalMethod, interval);
}, interval);
} else {
clearTimeout(this.pollTimers[taskUrl]);
delete this.pollTimers[taskUrl];
finalMethod?.(response);
}
} catch (error) {
clearTimeout(this.pollTimers[taskUrl]);
delete this.pollTimers[taskUrl];
this.modalError(error);
}
},
thingActionUrl(thing, action, allowUndefined = false) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
action,
"invokeaction",
allowUndefined,
);
return url;
},
},
};

View file

@ -0,0 +1,127 @@
/**
* @fileoverview
* Global mixin providing modal functionality.
*
* This mixin is registered globally in `main.js` using. Do not
* manually import it in components.
*/
import UIkit from "uikit";
export default {
methods: {
modalConfirm: function (modalText) {
var context = this;
// Stop GPU preview to show modal
context.$root.$emit("globalTogglePreview", false);
// force OK to be capitalised
UIkit.modal.i18n = { ok: "OK", cancel: "Cancel" };
var showModal = function (resolve, reject) {
UIkit.modal
.confirm(modalText, { stack: true })
.then(
function () {
resolve();
},
function () {
reject();
},
)
.finally(function () {
// Re-enable the GPU preview, if it was active before the modal
if (context.$store.state.autoGpuPreview) {
context.$root.$emit("globalTogglePreview", true);
}
context.$root.$emit("modalClosed");
});
};
return new Promise(showModal);
},
modalNotify: function (message, status = "success") {
UIkit.notification({
message: message,
status: status,
});
},
modalDialog: function (title, message) {
UIkit.modal.dialog(
`
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">${title}</h2>
</div>
<div class="uk-modal-body">
<p>${message}</p>
</div>
`,
{ stack: true },
);
},
modalError: function (error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
UIkit.notification({
message: `${errormsg}`,
status: "danger",
});
},
getErrorMessage: function (error) {
// Format the error.
let data = this.getErrorData(error);
// Get error data may get an object. Handle edge cases and if not try to use
// JSON to get the best string. This stops "objectObject" showing as an error.
if (data === null) return "null";
if (data === undefined) return "undefined";
if (typeof data === "string") return data;
try {
return JSON.stringify(data, null, 2);
} catch (err) {
return String(data);
}
},
getErrorData: function (error) {
// If a response was obtained, extract the most specific message
if (error.response) {
// If the response is a nicely formatted JSON response from the server
if (error.response.data.message) {
return error.response.data.message;
}
if (error.response.data.detail) {
try {
return error.response.data.detail[0].msg;
} catch (err) {
return error.response.data.detail;
}
}
// If the response is just some generic error response
if (error.response.data) {
return error.response.data;
}
return error.response;
}
// If we have an error object with a message, use that
if (error.message) return error.message;
// At this point just formatting the whole error object is the best we can do.
return error;
},
showModalElement: function (element) {
UIkit.modal(element).show();
},
hideModalElement: function (element) {
UIkit.modal(element).hide();
},
toggleModalElement: function (element) {
UIkit.modal(element).toggle();
},
},
};

View file

@ -66,7 +66,6 @@ export default new Vuex.Store({
error: "", error: "",
autoGpuPreview: false, autoGpuPreview: false,
trackWindow: true, trackWindow: true,
galleryEnabled: true,
activeStreams: {}, activeStreams: {},
microscopeHostname: "", microscopeHostname: "",
// Persistent items: // Persistent items:
@ -108,9 +107,6 @@ export default new Vuex.Store({
changeAppTheme(state, theme) { changeAppTheme(state, theme) {
state.appTheme = theme; state.appTheme = theme;
}, },
changeGalleryEnabled(state, enabled) {
state.galleryEnabled = enabled;
},
resetState(state) { resetState(state) {
state.waiting = false; state.waiting = false;
state.available = false; state.available = false;

View file

@ -51,12 +51,22 @@ export const wotStoreModule = {
thingDescriptions: (state) => { thingDescriptions: (state) => {
return state.thingDescriptions; return state.thingDescriptions;
}, },
thingList: (state) => {
return Object.keys(state.thingDescriptions);
},
thingDescription: (state) => (thingName) => { thingDescription: (state) => (thingName) => {
return state.thingDescriptions[thingName]; return state.thingDescriptions[thingName];
}, },
thingAvailable: (state) => (thingName) => { thingAvailable: (state) => (thingName) => {
return thingName in state.thingDescriptions; return thingName in state.thingDescriptions;
}, },
thingAffordanceAvailable: (state) => (thing, affordanceType, affordance) => {
let td = state.thingDescriptions[thing];
if (!td) {
return false;
}
return affordance in td[affordanceType];
},
thingFormUrl: thingFormUrl:
(state) => (state) =>
(thing, affordanceType, affordance, op, allowUndefined = true) => { (thing, affordanceType, affordance, op, allowUndefined = true) => {