Merge branch 'vue2-3_goal_1' into 'v3'

Vue2-3 migration.

See merge request openflexure/openflexure-microscope-server!479
This commit is contained in:
Julian Stirling 2026-02-16 12:29:38 +00:00
commit 11beb79a77
58 changed files with 2725 additions and 10968 deletions

View file

@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from argparse import Namespace from argparse import Namespace
from copy import copy from copy import copy
from functools import wraps from functools import wraps
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
import uvicorn import uvicorn
from fastapi.middleware.cors import CORSMiddleware # vue3 migration
from uvicorn.main import Server from uvicorn.main import Server
import labthings_fastapi as lt import labthings_fastapi as lt
@ -28,6 +30,7 @@ from .legacy_api import add_v2_endpoints
from .serve_static_files import add_static_files from .serve_static_files import add_static_files
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEVELOPER_MODE = os.getenv("OFM_SERVER_DEV_MODE", "false").lower() == "true"
def set_shutdown_function(shutdown_function: Callable[[], None]) -> None: def set_shutdown_function(shutdown_function: Callable[[], None]) -> None:
@ -61,6 +64,17 @@ def customise_server(
) -> None: ) -> None:
"""Customise the server with additional endpoints, etc.""" """Customise the server with additional endpoints, etc."""
configure_logging(log_folder) configure_logging(log_folder)
if DEVELOPER_MODE:
# Allow CORS in developer mode for easier testing with the webapp
server.app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
add_v2_endpoints(server) add_v2_endpoints(server)
add_static_files(server.app, scans_folder) add_static_files(server.app, scans_folder)

View file

@ -85,8 +85,7 @@ def check_static_dir() -> None:
"web app." "web app."
) )
expected = [ expected = [
os.path.isdir(os.path.join(STATIC_PATH, "js")), os.path.isdir(os.path.join(STATIC_PATH, "assets")),
os.path.isdir(os.path.join(STATIC_PATH, "css")),
os.path.isfile(os.path.join(STATIC_PATH, "index.html")), os.path.isfile(os.path.join(STATIC_PATH, "index.html")),
] ]
if not all(expected): if not all(expected):

View file

@ -18,8 +18,7 @@ def mock_static_dir():
It contains enough files to be recognised as a webapp. It contains enough files to be recognised as a webapp.
""" """
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
os.makedirs(os.path.join(tmpdir, "js")) os.makedirs(os.path.join(tmpdir, "assets"))
os.makedirs(os.path.join(tmpdir, "css"))
with open(os.path.join(tmpdir, "index.html"), "w", encoding="utf-8") as f_obj: with open(os.path.join(tmpdir, "index.html"), "w", encoding="utf-8") as f_obj:
f_obj.write("<mock>mock</mock>") f_obj.write("<mock>mock</mock>")
yield tmpdir yield tmpdir
@ -95,21 +94,21 @@ def test_check_static_dir(mock_static_dir, mocker):
# First run shouldn't error as the mock dir has enough files. # First run shouldn't error as the mock dir has enough files.
serve_static_files.check_static_dir() serve_static_files.check_static_dir()
js_path = os.path.join(mock_static_dir, "js") asset_path = os.path.join(mock_static_dir, "assets")
index_path = os.path.join(mock_static_dir, "index.html") index_path = os.path.join(mock_static_dir, "index.html")
# Remove javascript dir and check error. # Remove asset dir and check error.
shutil.rmtree(js_path) shutil.rmtree(asset_path)
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
serve_static_files.check_static_dir() serve_static_files.check_static_dir()
# replace javascript dir with file, it should still error. # replace asset dir with file, it should still error.
shutil.copyfile(index_path, js_path) shutil.copyfile(index_path, asset_path)
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
serve_static_files.check_static_dir() serve_static_files.check_static_dir()
# Return it to a folder and check everything works again # Return it to a folder and check everything works again
os.remove(js_path) os.remove(asset_path)
os.makedirs(js_path) os.makedirs(asset_path)
serve_static_files.check_static_dir() serve_static_files.check_static_dir()
# But it errors again if index.html is removed. # But it errors again if index.html is removed.
@ -152,13 +151,11 @@ def test_add_static_files(mock_static_dir, mocker):
assert second_wrapped().headers["Pragma"] == "no-cache" assert second_wrapped().headers["Pragma"] == "no-cache"
# Also should have mounted both dirs # Also should have mounted both dirs
assert mock_app.mount.call_count == 2 assert mock_app.mount.call_count == 1
mounted_paths = [call.args[0] for call in mock_app.mount.call_args_list] mounted_path = mock_app.mount.call_args.args[0]
mounted_dirs = [call.args[1].directory for call in mock_app.mount.call_args_list] mounted_dir = mock_app.mount.call_args.args[1].directory
assert "/css/" in mounted_paths assert "/assets/" in mounted_path
assert "/js/" in mounted_paths assert os.path.join(mock_static_dir, "assets") in mounted_dir
assert os.path.join(mock_static_dir, "css") in mounted_dirs
assert os.path.join(mock_static_dir, "js") in mounted_dirs
def test_add_static_files_with_scan_dir(mock_static_dir, mocker): def test_add_static_files_with_scan_dir(mock_static_dir, mocker):

View file

@ -1,4 +1,3 @@
NODE_ENV=production
VUE_APP_MODE=production VUE_APP_MODE=production
VUE_APP_PLATFORM=web VUE_APP_PLATFORM=web
VUE_APP_TARGET=web VUE_APP_TARGET=web

View file

@ -1,23 +1,44 @@
module.exports = { module.exports = {
root: true, root: true,
env: { env: {
node: true, node: true,
es2021: true,
}, },
parser: "vue-eslint-parser", parser: "vue-eslint-parser",
parserOptions: { parserOptions: {
parser: "@babel/eslint-parser", parser: "@babel/eslint-parser",
requireConfigFile: false, requireConfigFile: false,
ecmaVersion: 2020, ecmaVersion: 2021,
sourceType: "module", sourceType: "module",
}, },
extends: ["plugin:vue/vue3-recommended", "eslint:recommended", "@vue/prettier"],
extends: ["plugin:vue/recommended", "eslint:recommended", "@vue/prettier"],
rules: { rules: {
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"vue/no-deprecated-slot-attribute": "warn",
"vue/no-deprecated-v-on-native-modifier": "warn",
"vue/no-deprecated-dollar-listeners-api": "warn",
"vue/no-deprecated-destroyed-lifecycle": "warn",
"vue/no-deprecated-dollar-scopedslots-api": "warn",
"vue/no-deprecated-events-api": "warn",
"vue/no-deprecated-filter": "warn",
"vue/no-deprecated-functional-template": "warn",
"vue/no-deprecated-html-element-is": "warn",
"vue/no-deprecated-inline-template": "warn",
"vue/no-deprecated-props-default-this": "warn",
"vue/no-deprecated-scope-attribute": "warn",
"vue/no-deprecated-v-bind-sync": "warn",
"vue/no-deprecated-v-is": "warn",
"vue/no-lifecycle-after-await": "warn",
"vue/no-watch-after-await": "warn",
"vue/require-explicit-emits": "warn", // Require emits declaration
"vue/multi-word-component-names": "warn", // Component naming
"vue/no-v-for-template-key-on-child": "error", // Key placement
"vue/no-v-model-argument": "off", // Allow v-model arguments (Vue 3 feature)
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
}, },
}; };

View file

@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/app"],
};

27
webapp/index.html Normal file
View file

@ -0,0 +1,27 @@
<!--
index.html
The main HTML file for the OpenFlexure Microscope web application.
Vite will use this file as a template to build the final HTML served to users.
It's at root directory to allow Vite to process it.
The previous version was located at public/index.html.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>OpenFlexure Microscope</title>
</head>
<body>
<noscript>
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

12680
webapp/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,41 +6,37 @@
"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": "vue-cli-service serve --mode development", "serve": "vite serve --mode development",
"build": "vue-cli-service build --mode production", "build": "vite build --mode production",
"lint": "vue-cli-service lint --no-fix --max-warnings 0", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore",
"lint:fix": "vue-cli-service lint --fix" "lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.1.0",
"axios": "^1.7.7",
"material-design-icons": "^3.0", "material-design-icons": "^3.0",
"mitt": "^3.0.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"openseadragon": "^5.0.0", "openseadragon": "^5.0.0",
"vue-observe-visibility": "^0.4" "uikit": "^3.24.2",
"vue": "^3.4.0",
"vue-router": "4.0.0",
"vuejs-paginate-next": "^1.0.2",
"vuex": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.28.5", "@babel/eslint-parser": "^7.28.5",
"@vue/cli-plugin-babel": "^5.0.9", "@vitejs/plugin-vue": "^5.2.4",
"@vue/cli-plugin-eslint": "^5.0.9", "@vue/eslint-config-prettier": "9.0.0",
"@vue/cli-plugin-router": "^5.0.9", "autoprefixer": "^10.4.24",
"@vue/cli-plugin-vuex": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/eslint-config-prettier": "^7.1.0",
"axios": "^1.13.1",
"core-js": "^3.46.0", "core-js": "^3.46.0",
"css-loader": "^3.6", "eslint": "^8.57.0",
"eslint": "^7.32.0", "eslint-plugin-prettier": "^5.1.0",
"eslint-config-prettier": "^8.10.2", "eslint-plugin-vue": "^9.20.0",
"eslint-plugin-prettier": "^3.4.1", "less": "^4.5.1",
"eslint-plugin-vue": "^7.20.0",
"less": "^4.4.2",
"less-loader": "^11.1.4",
"material-symbols": "^0.39.1", "material-symbols": "^0.39.1",
"prettier": "^2.8.8", "prettier": "^3.2.8",
"uikit": "^3.24.2", "vite": "^5.4.21"
"vue": "^2.7",
"vue-template-compiler": "^2.7",
"vuejs-paginate": "^2.1",
"vuex": "^3.6"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>OpenFlexure Microscope</title>
</head>
<body>
<noscript>
<strong>We're sorry but vue3 doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -26,12 +26,12 @@
// Import components // Import components
import appContent from "./components/appContent.vue"; import appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue"; import loadingContent from "./components/loadingContent.vue";
import Mousetrap from "mousetrap";
var Mousetrap = require("mousetrap"); import { eventBus } from "./eventBus.js";
Mousetrap.prototype.stopCallback = function (e, element) { Mousetrap.prototype.stopCallback = function (e, element) {
// if the element has the class "mousetrap" then no need to stop // if the element has the class "mousetrap" then no need to stop
if ((" " + element.className + " ").indexOf(" mousetrap ") > -1) { if ((" " + element.className + " ").indexOf(" Mousetrap ") > -1) {
return false; return false;
} }
@ -153,10 +153,10 @@ export default {
// Focus keys // Focus keys
Mousetrap.bind("pageup", () => { Mousetrap.bind("pageup", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, 1); eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: 1 });
}); });
Mousetrap.bind("pagedown", () => { Mousetrap.bind("pagedown", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, -1); eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: -1 });
}); });
this.keyboardManual.push({ this.keyboardManual.push({
shortcut: "pgup / pgdn", shortcut: "pgup / pgdn",
@ -165,7 +165,7 @@ export default {
// Capture // Capture
Mousetrap.bind("c", () => { Mousetrap.bind("c", () => {
this.$root.$emit("globalCaptureEvent"); eventBus.emit("globalCaptureEvent", {});
}); });
this.keyboardManual.push({ this.keyboardManual.push({
shortcut: "c", shortcut: "c",
@ -174,7 +174,7 @@ export default {
// Autofocus // Autofocus
Mousetrap.bind("a", () => { Mousetrap.bind("a", () => {
this.$root.$emit("globalFastAutofocusEvent"); eventBus.emit("globalFastAutofocusEvent", {});
}); });
this.keyboardManual.push({ this.keyboardManual.push({
shortcut: "a", shortcut: "a",
@ -183,10 +183,10 @@ export default {
// Increment/decrement tab // Increment/decrement tab
Mousetrap.bind("shift+down", () => { Mousetrap.bind("shift+down", () => {
this.$root.$emit("globalIncrementTab"); eventBus.emit("globalIncrementTab", {});
}); });
Mousetrap.bind("shift+up", () => { Mousetrap.bind("shift+up", () => {
this.$root.$emit("globalDecrementTab"); eventBus.emit("globalDecrementTab", {});
}); });
this.keyboardManual.push({ this.keyboardManual.push({
shortcut: "shift+↑ / shift+↓", shortcut: "shift+↑ / shift+↓",
@ -194,7 +194,7 @@ export default {
}); });
}, },
beforeDestroy: function () { beforeUnmount: function () {
// Disconnect the theme observer // Disconnect the theme observer
if (this.themeObserver) { if (this.themeObserver) {
this.themeObserver.disconnect(); this.themeObserver.disconnect();
@ -237,7 +237,7 @@ export default {
} }
}, },
handleExit: function () { handleExit: function () {
this.$root.$emit("globalTogglePreview", false); eventBus.emit("globalTogglePreview", false);
}, },
// Handle global mouse wheel events to be associated with navigation // Handle global mouse wheel events to be associated with navigation
@ -247,9 +247,14 @@ export default {
event.target.parentNode.classList.contains("scrollTarget") || event.target.parentNode.classList.contains("scrollTarget") ||
event.target.classList.contains("scrollTarget") event.target.classList.contains("scrollTarget")
) { ) {
var z_rel = event.deltaY / 100; const z_steps = event.deltaY / 100;
// Emit a signal to move, acted on by panelControl.vue // Emit a signal to move, acted on by panelControl.vue
this.$root.$emit("globalMoveStepEvent", 0, 0, z_rel, false); eventBus.emit("globalMoveStepEvent", {
x_steps: 0,
y_steps: 0,
z_steps: z_steps,
absolute: false,
});
} }
}, },
@ -257,7 +262,6 @@ export default {
// Calculate movement array // Calculate movement array
var x_rel = 0; var x_rel = 0;
var y_rel = 0; var y_rel = 0;
var z_rel = 0;
// 37 corresponds to the left key // 37 corresponds to the left key
if (37 in this.arrowKeysDown) { if (37 in this.arrowKeysDown) {
x_rel = x_rel - 1; x_rel = x_rel - 1;
@ -276,7 +280,7 @@ export default {
} }
// Make a position request // Make a position request
// Emit a signal to move, acted on by panelControl.vue // Emit a signal to move, acted on by panelControl.vue
this.$root.$emit("globalMoveStepEvent", x_rel, y_rel, z_rel); eventBus.emit("globalMoveStepEvent", { x: x_rel, y: y_rel, z: 0 });
}, },
}, },
}; };

View file

@ -1,7 +1,7 @@
<template> <template>
<div id="app-content" class="uk-margin-remove uk-padding-remove uk-height-1-1" uk-grid> <div id="app-content" class="uk-margin-remove uk-padding-remove uk-height-1-1" uk-grid>
<!-- Initialisation modals --> <!-- Initialisation modals -->
<calibrationWizard ref="calibrationWizard" @onClose="enterApp()"></calibrationWizard> <calibrationWizard ref="calibrationWizard" @on-close="enterApp()"></calibrationWizard>
<!-- Vertical tab bar --> <!-- Vertical tab bar -->
<div id="switcher-left-container"> <div id="switcher-left-container">
<div <div
@ -9,11 +9,10 @@
class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center" class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center"
> >
<!-- For each top tab --> <!-- For each top tab -->
<template v-for="(item, index) in topTabs"> <template v-for="(item, index) in topTabs" :key="item.id + '-tab-icon'">
<!-- Render the tab icon --> <!-- Render the tab icon -->
<tabIcon <tabIcon
:id="item.id + '-tab-icon'" :id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'"
:tab-i-d="item.id" :tab-i-d="item.id"
:title="item.title" :title="item.title"
:require-connection="true" :require-connection="true"
@ -37,11 +36,10 @@
<hr id="extension-tab-divider" /> <hr id="extension-tab-divider" />
<!-- For each bottom tab --> <!-- For each bottom tab -->
<template v-for="(item, index) in bottomTabs"> <template v-for="(item, index) in bottomTabs" :key="item.id + '-tab-icon'">
<!-- Render the tab icon --> <!-- Render the tab icon -->
<tabIcon <tabIcon
:id="item.id + '-tab-icon'" :id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'"
:tab-i-d="item.id" :tab-i-d="item.id"
:title="item.title" :title="item.title"
:require-connection="true" :require-connection="true"
@ -71,7 +69,7 @@
:require-connection="true" :require-connection="true"
:current-tab="currentTab" :current-tab="currentTab"
> >
<component :is="item.component" @scrollTop="scrollToTop"></component> <component :is="item.component" @scroll-top="scrollToTop"></component>
</tabContent> </tabContent>
</div> </div>
</div> </div>
@ -79,8 +77,8 @@
<script> <script>
// Import generic components // Import generic components
import tabIcon from "./genericComponents/tabIcon"; import tabIcon from "./genericComponents/tabIcon.vue";
import tabContent from "./genericComponents/tabContent"; import tabContent from "./genericComponents/tabContent.vue";
// Import new content components // Import new content components
import aboutContent from "./tabContentComponents/aboutContent.vue"; import aboutContent from "./tabContentComponents/aboutContent.vue";
@ -92,6 +90,8 @@ import scanListContent from "./tabContentComponents/scanListContent.vue";
import settingsContent from "./tabContentComponents/settingsContent.vue"; import settingsContent from "./tabContentComponents/settingsContent.vue";
import slideScanContent from "./tabContentComponents/slideScanContent.vue"; import slideScanContent from "./tabContentComponents/slideScanContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue"; import viewContent from "./tabContentComponents/viewContent.vue";
import { markRaw } from "vue";
import { eventBus } from "../eventBus.js";
// Import modal components for device initialisation // Import modal components for device initialisation
import calibrationWizard from "./modalComponents/calibrationWizard.vue"; import calibrationWizard from "./modalComponents/calibrationWizard.vue";
@ -113,26 +113,26 @@ export default {
id: "settings", id: "settings",
title: "Settings", title: "Settings",
icon: "settings", icon: "settings",
component: settingsContent, component: markRaw(settingsContent),
class: "uk-margin-auto-top", class: "uk-margin-auto-top",
}, },
{ {
id: "logging", id: "logging",
title: "Logging", title: "Logging",
icon: "assignment_late", icon: "assignment_late",
component: loggingContent, component: markRaw(loggingContent),
}, },
{ {
id: "about", id: "about",
title: "About", title: "About",
icon: "info", icon: "info",
component: aboutContent, component: markRaw(aboutContent),
}, },
{ {
id: "power", id: "power",
title: "Power", title: "Power",
icon: "power_settings_new", icon: "power_settings_new",
component: powerContent, component: markRaw(powerContent),
}, },
], ],
coreTopTabs: [ coreTopTabs: [
@ -140,21 +140,21 @@ export default {
id: "view", id: "view",
title: "View", title: "View",
icon: "visibility", icon: "visibility",
component: viewContent, component: markRaw(viewContent),
requiredThings: [], requiredThings: [],
}, },
{ {
id: "control", id: "control",
title: "Control", title: "Control",
icon: "gamepad", icon: "gamepad",
component: controlContent, component: markRaw(controlContent),
requiredThings: [], requiredThings: [],
}, },
{ {
id: "background-detect", id: "background-detect",
title: "Background Detect", title: "Background Detect",
icon: "background_replace", icon: "background_replace",
component: backgroundDetectContent, component: markRaw(backgroundDetectContent),
// While stage isn't needed; automatic background detect has little function // While stage isn't needed; automatic background detect has little function
// for a manual microscope. // for a manual microscope.
requiredThings: ["stage"], requiredThings: ["stage"],
@ -163,14 +163,14 @@ export default {
id: "slide-scan", id: "slide-scan",
title: "Slide Scan", title: "Slide Scan",
icon: "settings_overscan", icon: "settings_overscan",
component: slideScanContent, component: markRaw(slideScanContent),
requiredThings: ["smart_scan"], requiredThings: ["smart_scan"],
}, },
{ {
id: "scan-list", id: "scan-list",
title: "Scan List", title: "Scan List",
icon: "photo_library", icon: "photo_library",
component: scanListContent, component: markRaw(scanListContent),
requiredThings: ["smart_scan"], requiredThings: ["smart_scan"],
}, },
], ],
@ -207,15 +207,15 @@ export default {
mounted() { mounted() {
// A global signal listener to switch tab // A global signal listener to switch tab
this.$root.$on("globalSwitchTab", (tabID) => { eventBus.on("globalSwitchTab", (tabID) => {
this.currentTab = tabID; this.currentTab = tabID;
}); });
// A global signal listener to increment tab // A global signal listener to increment tab
this.$root.$on("globalIncrementTab", () => { eventBus.on("globalIncrementTab", () => {
this.incrementTabBy(1); this.incrementTabBy(1);
}); });
// A global signal listener to decrement tab // A global signal listener to decrement tab
this.$root.$on("globalDecrementTab", () => { eventBus.on("globalDecrementTab", () => {
this.incrementTabBy(-1); this.incrementTabBy(-1);
}); });
if (this.$store.getters.ready) { if (this.$store.getters.ready) {

View file

@ -2,7 +2,6 @@
<div <div
id="stream-display" id="stream-display"
ref="streamDisplay" ref="streamDisplay"
v-observe-visibility="visibilityChanged"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget" class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
> >
<img <img
@ -16,6 +15,8 @@
</template> </template>
<script> <script>
import { useIntersectionObserver } from "@vueuse/core";
// Export main app // Export main app
export default { export default {
name: "MiniStreamDisplay", name: "MiniStreamDisplay",
@ -31,10 +32,17 @@ export default {
return `${this.$store.getters.baseUri}/camera/mjpeg_stream`; return `${this.$store.getters.baseUri}/camera/mjpeg_stream`;
}, },
}, },
methods: {
visibilityChanged(isVisible) { mounted() {
this.isVisible = isVisible; useIntersectionObserver(
}, this.$refs.streamDisplay,
([{ isIntersecting }]) => {
this.isVisible = isIntersecting;
},
{
threshold: 0.0,
},
);
}, },
}; };
</script> </script>

View file

@ -10,6 +10,8 @@ export default {
props: {}, props: {},
emits: ["set-tab"],
computed: { computed: {
tooltipOptions: function () { tooltipOptions: function () {
var title = this.id.charAt(0).toUpperCase() + this.id.slice(1); var title = this.id.charAt(0).toUpperCase() + this.id.slice(1);

View file

@ -1,5 +1,11 @@
<template> <template>
<a href="#" class="uk-link" :class="classObject" :uk-tooltip="tooltipOptions" @click="setThisTab"> <a
href="#"
class="uk-link"
:class="classObject"
:uk-tooltip="tooltipOptions ? tooltipOptions : null"
@click="setThisTab"
>
<slot></slot> <slot></slot>
<div v-if="showTitle" class="tabtitle"> <div v-if="showTitle" class="tabtitle">
{{ computedTitle }} {{ computedTitle }}
@ -43,6 +49,8 @@ export default {
requireConnection: Boolean, requireConnection: Boolean,
}, },
emits: ["set-tab"],
computed: { computed: {
computedTitle: function () { computedTitle: function () {
if (this.title !== undefined) { if (this.title !== undefined) {

View file

@ -1,6 +1,7 @@
<template> <template>
<div v-observe-visibility="visibilityChanged" class="uk-margin-remove uk-padding-remove"> <div class="uk-margin-remove uk-padding-remove">
<button <button
ref="actionButton"
type="button" type="button"
:disabled="buttonDisabled" :disabled="buttonDisabled"
class="uk-button uk-width-1-1 uk-position-relative" class="uk-button uk-width-1-1 uk-position-relative"
@ -20,7 +21,7 @@
:task-running="taskRunning" :task-running="taskRunning"
:task-started="taskStarted" :task-started="taskStarted"
:task-status="taskStatus" :task-status="taskStatus"
@terminateTask="terminateTask" @terminate-task="terminateTask"
/> />
</div> </div>
</template> </template>
@ -28,10 +29,16 @@
<script> <script>
import ActionProgressBar from "./actionProgressBar.vue"; import ActionProgressBar from "./actionProgressBar.vue";
import ActionStatusModal from "./actionStatusModal.vue"; import ActionStatusModal from "./actionStatusModal.vue";
import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "ActionButton", name: "ActionButton",
components: { ActionProgressBar, ActionStatusModal },
components: {
ActionProgressBar,
ActionStatusModal,
},
props: { props: {
action: { action: {
@ -94,6 +101,22 @@ export default {
}, },
}, },
emits: [
"update:progress",
"update:taskStarted",
"update:taskRunning",
"update:log",
"update:taskStatus",
"submit",
"taskStarted",
"taskRunning",
"response",
"completed",
"cancelled",
"finished",
"error",
],
data: function () { data: function () {
return { return {
taskUrl: null, taskUrl: null,
@ -129,31 +152,52 @@ export default {
}, },
watch: { watch: {
progress(newval) { progress: {
this.$emit("update:progress", newval); handler(newval) {
this.$emit("update:progress", newval);
},
}, },
taskStarted(newval) { taskStarted: {
this.$emit("update:taskStarted", newval); handler(newval) {
this.$emit("update:taskStarted", newval);
},
}, },
taskRunning(newval) { taskRunning: {
this.$emit("update:taskRunning", newval); handler(newval) {
this.$emit("update:taskRunning", newval);
},
}, },
log(newval) { log: {
this.$emit("update:log", newval); handler(newval) {
this.$emit("update:log", newval);
},
deep: true,
}, },
taskStatus(newval) { taskStatus: {
this.$emit("update:taskStatus", newval); handler(newval) {
this.$emit("update:taskStatus", newval);
},
}, },
}, },
mounted() { mounted() {
useIntersectionObserver(
this.$refs.actionButton,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0, // Adjust as needed
},
);
// Check for already running tasks // Check for already running tasks
if (this.taskStarted != true) { if (this.taskStarted != true) {
this.checkExistingTasks(); this.checkExistingTasks();
} }
// 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, () => { eventBus.on(this.submitOnEvent, () => {
if (this.isDisabled) return; if (this.isDisabled) return;
// Bootstrap task if button is not disabled. // Bootstrap task if button is not disabled.
this.bootstrapTask(); this.bootstrapTask();
@ -161,9 +205,9 @@ export default {
} }
}, },
beforeDestroy() { beforeUnmount() {
if (this.submitOnEvent) { if (this.submitOnEvent) {
this.$root.$off(this.submitOnEvent); eventBus.off(this.submitOnEvent);
} }
}, },

View file

@ -60,11 +60,16 @@ export default {
}, },
watch: { watch: {
log: function () { log: {
this.scrollToBottom(); handler() {
this.scrollToBottom();
},
deep: true,
}, },
taskStatus: function () { taskStatus: {
this.scrollToBottom(); handler() {
this.scrollToBottom();
},
}, },
}, },

View file

@ -28,10 +28,14 @@
import UIkit from "uikit"; import UIkit from "uikit";
import ActionProgressBar from "./actionProgressBar.vue"; import ActionProgressBar from "./actionProgressBar.vue";
import ActionLogDisplay from "./actionLogDisplay.vue"; import ActionLogDisplay from "./actionLogDisplay.vue";
import { eventBus } from "../../eventBus.js";
export default { export default {
name: "ActionStatusModal", name: "ActionStatusModal",
components: { ActionProgressBar, ActionLogDisplay }, components: {
ActionProgressBar,
ActionLogDisplay,
},
props: { props: {
title: { title: {
@ -65,13 +69,15 @@ export default {
}, },
}, },
emits: ["terminateTask"],
methods: { methods: {
show() { show() {
UIkit.modal(this.$refs.modal).show(); UIkit.modal(this.$refs.modal).show();
}, },
hide() { hide() {
UIkit.modal(this.$refs.modal).hide(); UIkit.modal(this.$refs.modal).hide();
this.$root.$emit("modalClosed"); eventBus.emit("modalClosed");
}, },
}, },
}; };

View file

@ -4,11 +4,12 @@
>{{ label }} >{{ label }}
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
ref="numericalInput"
v-model="internalValue" v-model="internalValue"
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@focusin="focusIn" @input="grabFocus"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd" @animationend="animationEnd"
@ -39,8 +40,7 @@
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@input="updateIsEdited" @input="grabFocus"
@focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd" @animationend="animationEnd"
@ -50,7 +50,7 @@
</label> </label>
<label v-if="dataType == 'number_object'" class="uk-form-label" <label v-if="dataType == 'number_object'" class="uk-form-label"
>{{ label }} >{{ label }}
<div v-for="(val, key) in value" :key="key"> <div v-for="(val, key) in modelValue" :key="key">
<label>{{ internalLabels[key] }}</label> <label>{{ internalLabels[key] }}</label>
<div class="input-and-buttons-container"> <div class="input-and-buttons-container">
<input <input
@ -58,8 +58,7 @@
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
type="number" type="number"
@input="updateIsEdited" @input="grabFocus"
@focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd" @animationend="animationEnd"
@ -76,7 +75,6 @@
class="uk-form-small numeric-setting-line-input" class="uk-form-small numeric-setting-line-input"
:class="{ edited: isEdited, flash: animateUpdate }" :class="{ edited: isEdited, flash: animateUpdate }"
type="text" type="text"
@focusin="focusIn"
@focusout="focusOut" @focusout="focusOut"
@keydown="keyDown" @keydown="keyDown"
@animationend="animationEnd" @animationend="animationEnd"
@ -107,15 +105,15 @@ export default {
components: { components: {
syncPropertyButton, syncPropertyButton,
}, },
props: { props: {
dataSchema: { dataSchema: {
type: Object, type: Object,
required: true, required: true,
}, },
value: { modelValue: {
type: null, type: null,
required: true, required: false,
default: undefined,
}, },
label: { label: {
type: String, type: String,
@ -123,24 +121,28 @@ export default {
}, },
animate: { animate: {
type: Boolean, type: Boolean,
default: false, default: null,
}, },
}, },
emits: ["requestUpdate", "sendValue", "animationShown"],
compatConfig: { COMPONENT_V_MODEL: false },
data() { data() {
return { return {
// Initialise with a copy to try to prevent the this.value prop being mutated if // Initialise with a copy to try to prevent the this.modelValue prop being mutated if
// the value is an array or object. For future updates we stringify and parse // the modelValue is an array or object. For future updates we stringify and parse
// (see resetInternalValue). If we do this here there is a chance we get errors // (see resetInternalValue). If we do this here there is a chance we get errors
// as internalValue is still null when rendering starts. // as internalValue is still null when rendering starts.
internalValue: Array.isArray(this.value) internalValue: Array.isArray(this.modelValue)
? [...this.value] ? [...this.modelValue]
: typeof this.value === "object" : typeof this.modelValue === "object"
? { ...this.value } ? { ...this.modelValue }
: this.value, : this.modelValue,
// Is edited can't be computed as we mutate internalValue // Is edited can't be computed as we mutate internalValue
isEdited: false, isEdited: false,
animateUpdate: false, animateUpdate: null,
}; };
}, },
computed: { computed: {
@ -206,14 +208,19 @@ export default {
}, },
watch: { watch: {
value() { modelValue: {
// Fire updateIsEdited on both value and internal value change, deep: true,
// as change in value may not causse internalValue to change. handler() {
this.updateIsEdited(); // Fire updateIsEdited on both modelValue and internal modelValue change,
this.resetInternalValue(); // as change in modelValue may not cause internalValue to change.
this.updateIsEdited();
this.resetInternalValue();
},
}, },
internalValue() { internalValue: {
this.updateIsEdited(); handler() {
this.updateIsEdited();
},
}, },
animate(updated) { animate(updated) {
if (updated) { if (updated) {
@ -223,19 +230,20 @@ export default {
}, },
mounted() { mounted() {
if (this.value !== undefined) { if (this.modelValue !== undefined) {
this.resetInternalValue(); this.resetInternalValue();
} }
}, },
methods: { methods: {
resetInternalValue: function () { resetInternalValue: function () {
// Whenever updatirng th internal value stringify and parse as a form of deepcopy. // Whenever updatirng th internal modelValue stringify and parse as a form of deepcopy.
// This ensure that the this.value prop is not mutated for when elements of arrays // This ensure that the this.modelValue prop is not mutated for when elements of arrays
// or objects are updated. // or objects are updated.
this.internalValue = JSON.parse(JSON.stringify(this.value)); this.internalValue = JSON.parse(JSON.stringify(this.modelValue));
}, },
requestUpdate: async function () { requestUpdate: async function () {
this.internalValue = this.modelValue;
this.$emit("requestUpdate"); this.$emit("requestUpdate");
}, },
sendValue: async function () { sendValue: async function () {
@ -247,11 +255,18 @@ export default {
this.sendValue(); this.sendValue();
} }
}, },
focusIn: function (event) { /** Grab the browser focus.
this.valueOnEnter = event.target.value; *
* This is needed for numerical elements to be in focus when a spinner
* is clicked so that the data sends when focus is lost.
*/
grabFocus(event) {
event.target.focus();
// This is needed for number objects and number arrays
this.updateIsEdited();
}, },
focusOut: function (event) { focusOut: function (event) {
if (this.valueOnEnter != event.target.value) { if (this.modelValue != event.target.value) {
this.sendValue(event.target.value); this.sendValue(event.target.value);
} }
}, },
@ -262,15 +277,16 @@ export default {
} }
}, },
updateIsEdited: function () { updateIsEdited: function () {
this.isEdited = this.deepStringify(this.internalValue) !== this.deepStringify(this.value); this.isEdited =
this.deepStringify(this.internalValue) !== this.deepStringify(this.modelValue);
}, },
animationEnd: function () { animationEnd: function () {
this.animateUpdate = false; this.animateUpdate = null;
this.$emit("animationShown"); this.$emit("animationShown");
}, },
deepStringify: function (val) { deepStringify: function (val) {
// Create a json string where all internal numbers are also JSON strings. This is // Create a json string where all internal numbers are also JSON strings. This is
// needed to robustly check if the value is updated because the raw value may be a // needed to robustly check if the modelValue is updated because the raw modelValue may be a
// number but anything typed in the input is a string. In the case of arrays or // number but anything typed in the input is a string. In the case of arrays or
// objects even with JSON.stringify we end up comparing ["1", 3] with [1, 3] and // objects even with JSON.stringify we end up comparing ["1", 3] with [1, 3] and
// find them as not equal. // find them as not equal.

View file

@ -1,12 +1,12 @@
<template> <template>
<input-from-schema <input-from-schema
v-model="value" v-model="modelValue"
:data-schema="propertyDescription" :data-schema="propertyDescription"
:label="label" :label="label"
:animate="animate" :animate="animate"
@requestUpdate="readProperty" @request-update="readProperty"
@sendValue="writeProperty" @send-value="writeProperty"
@animationShown="resetAnimate" @animation-shown="resetAnimate"
/> />
</template> </template>
@ -48,7 +48,7 @@ export default {
data() { data() {
return { return {
value: undefined, modelValue: undefined,
animate: false, animate: false,
}; };
}, },
@ -74,7 +74,7 @@ export default {
// Read the property when we're mounted - usually this won't // Read the property when we're mounted - usually this won't
// work because the URL isn't set yet. However, it's helpful if // work because the URL isn't set yet. However, it's helpful if
// the app is reloaded (e.g. from a dev server). // the app is reloaded (e.g. from a dev server).
if (this.value == undefined) { if (this.modelValue == undefined) {
this.readProperty(); this.readProperty();
} }
}, },
@ -82,12 +82,12 @@ export default {
methods: { methods: {
readProperty: async function () { readProperty: async function () {
let data = await this.readThingProperty(this.thingName, this.propertyName); let data = await this.readThingProperty(this.thingName, this.propertyName);
this.value = data; this.modelValue = data;
return data; return data;
}, },
writeProperty: async function (requestedValue) { writeProperty: async function (requestedValue) {
try { try {
this.value = requestedValue; this.modelValue = requestedValue;
await this.writeThingProperty(this.thingName, this.propertyName, requestedValue); await this.writeThingProperty(this.thingName, this.propertyName, requestedValue);
if (this.readBack) { if (this.readBack) {
await new Promise((r) => setTimeout(r, this.readBackDelay)); await new Promise((r) => setTimeout(r, this.readBackDelay));

View file

@ -33,6 +33,8 @@ export default {
}, },
}, },
emits: ["response", "finished"],
methods: { methods: {
/** /**
* Runs when the ActionButton's action completes successfully. * Runs when the ActionButton's action completes successfully.

View file

@ -12,6 +12,8 @@
<script> <script>
export default { export default {
name: "SyncPropertyButton", name: "SyncPropertyButton",
emits: ["click"],
}; };
</script> </script>
@ -26,7 +28,9 @@ export default {
.material-symbols-outlined.sync-icon { .material-symbols-outlined.sync-icon {
color: #888; color: #888;
transition: transform 0.3s ease, color 0.3s ease; transition:
transform 0.3s ease,
color 0.3s ease;
} }
.material-symbols-outlined.sync-icon:hover { .material-symbols-outlined.sync-icon:hover {

View file

@ -19,7 +19,9 @@ import devTools from "./tabContentComponents/aboutComponents/devTools.vue";
export default { export default {
name: "LoadingContent", name: "LoadingContent",
components: { devTools }, components: {
devTools,
},
data: function () { data: function () {
return {}; return {};

View file

@ -4,10 +4,10 @@
<h2 class="uk-modal-title">Microscope Calibration</h2> <h2 class="uk-modal-title">Microscope Calibration</h2>
<component <component
v-bind="currentTask.props"
:is="currentTask.component" :is="currentTask.component"
v-if="currentTask" v-if="currentTask"
:key="taskIndex" :key="taskIndex"
v-bind="currentTask.props"
:first="isFirstTask" :first="isFirstTask"
:final="isFinalTask" :final="isFinalTask"
:start-on-last="movingBackward" :start-on-last="movingBackward"
@ -24,12 +24,15 @@ import welcomeStep from "./calibrationWizardComponents/welcomeStep.vue";
import cameraCalibrationTask from "./calibrationWizardComponents/cameraCalibrationTask.vue"; import cameraCalibrationTask from "./calibrationWizardComponents/cameraCalibrationTask.vue";
import cameraStageMappingTask from "./calibrationWizardComponents/cameraStageMappingTask.vue"; import cameraStageMappingTask from "./calibrationWizardComponents/cameraStageMappingTask.vue";
import finalStep from "./calibrationWizardComponents/finalStep.vue"; import finalStep from "./calibrationWizardComponents/finalStep.vue";
import { markRaw } from "vue";
export default { export default {
name: "CalibrationWizard", name: "CalibrationWizard",
components: {}, components: {},
emits: ["onClose"],
data: function () { data: function () {
return { return {
isNeeded: undefined, isNeeded: undefined,
@ -103,7 +106,7 @@ export default {
// Optionally include the welcome screen // Optionally include the welcome screen
if (includeWelcome) { if (includeWelcome) {
tasks.push({ tasks.push({
component: singleStepTask, component: markRaw(singleStepTask),
props: { stepComponent: welcomeStep }, props: { stepComponent: welcomeStep },
}); });
} }
@ -111,12 +114,12 @@ export default {
// Add calibration task for each thing // Add calibration task for each thing
for (const thing of thingsToCal) { for (const thing of thingsToCal) {
const taskComponent = this.availableCalibrationTasks[thing]; const taskComponent = this.availableCalibrationTasks[thing];
tasks.push({ component: taskComponent }); tasks.push({ component: markRaw(taskComponent), props: {} });
} }
// Always include the final step // Always include the final step
tasks.push({ tasks.push({
component: singleStepTask, component: markRaw(singleStepTask),
props: { stepComponent: finalStep }, props: { stepComponent: finalStep },
}); });

View file

@ -23,10 +23,10 @@ gets very confusing.
<div> <div>
<h3 v-if="title">{{ title }}</h3> <h3 v-if="title">{{ title }}</h3>
<component <component
v-bind="currentStep.props"
:is="currentStep.component" :is="currentStep.component"
v-if="currentStep" v-if="currentStep"
:key="stepIndex" :key="stepIndex"
v-bind="currentStep.props"
@awaiting-user="handleAwaitingUser" @awaiting-user="handleAwaitingUser"
/> />
<p class="uk-text-right"> <p class="uk-text-right">
@ -66,6 +66,8 @@ export default {
}, },
}, },
emits: ["back", "next"],
data() { data() {
return { return {
stepIndex: this.startOnLast ? this.steps.length - 1 : 0, stepIndex: this.startOnLast ? this.steps.length - 1 : 0,

View file

@ -9,7 +9,7 @@
<cameraCalibrationSettings <cameraCalibrationSettings
:show-extra-settings="false" :show-extra-settings="false"
:camera-uri="cameraUri" :camera-uri="cameraUri"
@actionFinished="checkCalibrationState" @action-finished="checkCalibrationState"
/> />
</div> </div>
</template> </template>
@ -28,6 +28,8 @@ export default {
cameraCalibrationSettings, cameraCalibrationSettings,
}, },
emits: ["awaiting-user"],
computed: { computed: {
cameraUri: function () { cameraUri: function () {
return `${this.$store.getters.baseUri}/camera/`; return `${this.$store.getters.baseUri}/camera/`;

View file

@ -14,10 +14,14 @@
import calibrationWizardTask from "./calibrationWizardTask.vue"; import calibrationWizardTask from "./calibrationWizardTask.vue";
import camCalibrationExplanation from "./cameraCalibrationSteps/camCalibrationExplanation.vue"; import camCalibrationExplanation from "./cameraCalibrationSteps/camCalibrationExplanation.vue";
import cameraMainCalibrationStep from "./cameraCalibrationSteps/cameraMainCalibrationStep.vue"; import cameraMainCalibrationStep from "./cameraCalibrationSteps/cameraMainCalibrationStep.vue";
import { markRaw } from "vue";
export default { export default {
name: "CameraCalibrationTask", name: "CameraCalibrationTask",
components: { calibrationWizardTask }, components: {
calibrationWizardTask,
},
props: { props: {
// Standard calibrationWizardTask props below: // Standard calibrationWizardTask props below:
first: Boolean, first: Boolean,
@ -28,9 +32,14 @@ export default {
}, },
}, },
emits: ["next", "back"],
data: function () { data: function () {
return { return {
steps: [{ component: camCalibrationExplanation }, { component: cameraMainCalibrationStep }], steps: [
{ component: markRaw(camCalibrationExplanation) },
{ component: markRaw(cameraMainCalibrationStep) },
],
}; };
}, },
}; };

View file

@ -15,10 +15,13 @@ import calibrationWizardTask from "./calibrationWizardTask.vue";
import csmExplanation from "./csmSteps/csmExplanation.vue"; import csmExplanation from "./csmSteps/csmExplanation.vue";
import focusStep from "./csmSteps/focusStep.vue"; import focusStep from "./csmSteps/focusStep.vue";
import runCsmStep from "./csmSteps/runCsmStep.vue"; import runCsmStep from "./csmSteps/runCsmStep.vue";
import { markRaw } from "vue";
export default { export default {
name: "CameraCalibrationTask", name: "CameraCalibrationTask",
components: { calibrationWizardTask }, components: {
calibrationWizardTask,
},
props: { props: {
// Standard calibrationWizardTask props below: // Standard calibrationWizardTask props below:
first: Boolean, first: Boolean,
@ -28,10 +31,15 @@ export default {
default: false, default: false,
}, },
}, },
emits: ["next", "back"],
data: function () { data: function () {
return { return {
steps: [{ component: csmExplanation }, { component: focusStep }, { component: runCsmStep }], steps: [
{ component: markRaw(csmExplanation) },
{ component: markRaw(focusStep) },
{ component: markRaw(runCsmStep) },
],
}; };
}, },
}; };

View file

@ -30,6 +30,7 @@
<script> <script>
import stepTemplateWithStream from "../stepTemplateWithStream.vue"; import stepTemplateWithStream from "../stepTemplateWithStream.vue";
import ActionButton from "../../../labThingsComponents/actionButton.vue"; import ActionButton from "../../../labThingsComponents/actionButton.vue";
export default { export default {
name: "CameraMainCalibrationStep", name: "CameraMainCalibrationStep",
@ -47,7 +48,7 @@ export default {
gap: 8px; /* Small space between buttons */ gap: 8px; /* Small space between buttons */
margin-top: 4px; /* Gap from image */ margin-top: 4px; /* Gap from image */
} }
.moveZ >>> .uk-button.uk-width-1-1 { .moveZ :deep(.uk-button.uk-width-1-1) {
line-height: 50px; line-height: 50px;
font-size: 50px !important; font-size: 50px !important;
height: 60px; height: 60px;

View file

@ -8,7 +8,7 @@
<div class="action-button-container"> <div class="action-button-container">
<CSMCalibrationSettings <CSMCalibrationSettings
:show-extra-settings="false" :show-extra-settings="false"
@recalibrateResponse="checkCalibrationState" @recalibrate-response="checkCalibrationState"
/> />
</div> </div>
</template> </template>
@ -27,6 +27,8 @@ export default {
CSMCalibrationSettings, CSMCalibrationSettings,
}, },
emits: ["awaiting-user"],
mounted() { mounted() {
this.checkCalibrationState(); this.checkCalibrationState();
}, },

View file

@ -24,10 +24,14 @@ supplied by the wizard.
<script> <script>
import calibrationWizardTask from "./calibrationWizardTask.vue"; import calibrationWizardTask from "./calibrationWizardTask.vue";
import { markRaw } from "vue";
export default { export default {
name: "SingleStepTask", name: "SingleStepTask",
components: { calibrationWizardTask }, components: {
calibrationWizardTask,
},
props: { props: {
// This must be sent // This must be sent
stepComponent: { stepComponent: {
@ -52,9 +56,11 @@ export default {
}, },
}, },
emits: ["next", "back"],
data: function () { data: function () {
return { return {
steps: [{ component: this.stepComponent, props: this.stepProps }], steps: [{ component: markRaw(this.stepComponent), props: this.stepProps }],
}; };
}, },
}; };

View file

@ -67,7 +67,9 @@ import ActionButton from "../../labThingsComponents/actionButton.vue";
export default { export default {
name: "StatusPane", name: "StatusPane",
components: { ActionButton }, components: {
ActionButton,
},
data: function () { data: function () {
return { return {

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-observe-visibility="visibilityChanged" class="uk-padding-small"> <div ref="backgroundDetectContent" class="uk-padding-small">
<div> <div>
<ul uk-accordion="multiple: true"> <ul uk-accordion="multiple: true">
<li> <li>
@ -47,6 +47,7 @@
<script> <script>
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue"; import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
components: { components: {
@ -65,13 +66,35 @@ export default {
}, },
async created() { async created() {
this.readSettings(); await this.readSettings();
},
mounted() {
useIntersectionObserver(
this.$refs.backgroundDetectContent,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
}, },
methods: { methods: {
async safeReadSettings() {
if (!this.$store.state.connected) return;
try {
await this.readSettings();
} catch (error) {
console.error("Error reading background detector settings:", error);
}
},
visibilityChanged(isVisible) { visibilityChanged(isVisible) {
if (isVisible) { if (isVisible) {
this.readSettings(); this.safeReadSettings();
} }
}, },
alertBackgroundSet() { alertBackgroundSet() {

View file

@ -9,7 +9,7 @@
:submit-label="'Autofocus'" :submit-label="'Autofocus'"
:button-primary="true" :button-primary="true"
:submit-on-event="'globalFastAutofocusEvent'" :submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="onAutofocus" @task-started="onAutofocus"
@finished="afterAutofocus" @finished="afterAutofocus"
@error="modalError" @error="modalError"
/> />
@ -18,6 +18,7 @@
</template> </template>
<script> <script>
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import { eventBus } from "../../../eventBus.js";
export default { export default {
name: "AutofocusControl", name: "AutofocusControl",
@ -38,7 +39,7 @@ export default {
}, },
afterAutofocus() { afterAutofocus() {
this.isAutofocusing = false; this.isAutofocusing = false;
this.$root.$emit("globalUpdatePositionEvent"); eventBus.emit("globalUpdatePositionEvent");
}, },
}, },
}; };

View file

@ -59,7 +59,6 @@ export default {
let imageUri = response.output.href; let imageUri = response.output.href;
if (!imageUri) { if (!imageUri) {
this.modalError("No image URI returned from capture task."); this.modalError("No image URI returned from capture task.");
console.log(`Capture resulted in response ${response}`);
return; return;
} }
// To save the returned data, we make a virtual link and click it // To save the returned data, we make a virtual link and click it

View file

@ -61,6 +61,8 @@ and zero position buttons. It also includes the d-pad.
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue"; import syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue";
import stageControlButtons from "./stageControlButtons.vue"; import stageControlButtons from "./stageControlButtons.vue";
import { eventBus } from "../../../eventBus.js";
export default { export default {
name: "PaneControl", name: "PaneControl",
@ -84,40 +86,48 @@ export default {
}, },
async mounted() { async mounted() {
let self = this;
// A global signal listener to perform a move action // A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", self.move); eventBus.on("globalMoveEvent", this.move);
this.$root.$on("globalUpdatePositionEvent", self.updatePosition); // A global signal listener to update position text boxes
eventBus.on("globalUpdatePositionEvent", this.updatePosition);
// A global signal listener to perform a move action in pixels // A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => { eventBus.on("globalMoveInImageCoordinatesEvent", this.onMoveImage);
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// A global signal listener to perform a move in multiples of a step size // A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => { eventBus.on("globalMoveStepEvent", this.onMoveStep);
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 // Update the current position in text boxes
await this.updatePosition(); await this.updatePosition();
}, },
beforeDestroy() { beforeUnmount() {
// Remove global signal listener to perform a move action // Remove global signal listener to perform a move action
this.$root.$off("globalMoveEvent"); eventBus.off("globalMoveEvent", this.move);
this.$root.$off("globalMoveInImageCoordinatesEvent"); eventBus.off("globalUpdatePositionEvent", this.updatePosition);
this.$root.$off("globalMoveStepEvent"); eventBus.off("globalMoveInImageCoordinatesEvent", this.onMoveImage);
this.$root.$off("globalUpdatePositionEvent"); eventBus.off("globalMoveStepEvent", this.onMoveStep);
}, },
methods: { methods: {
timeout(ms) { timeout(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}, },
async move(x, y, z, absolute) {
onMoveImage(payload) {
this.moveInImageCoordinatesRequest(payload.x, payload.y, payload.absolute);
},
onMoveStep(payload) {
const { x: x_steps, y: y_steps, z: z_steps } = payload;
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);
const movePayload = { x, y, z, absolute: false };
eventBus.emit("globalMoveEvent", movePayload);
},
async move(payload) {
const { x, y, z, absolute } = payload;
// Move the stage, by updating the controls and starting a move task // Move the stage, by updating the controls and starting a move task
// This is equivalent to clicking the "move" button. // This is equivalent to clicking the "move" button.
if (this.moveLock) return; // Discard move requests if we're already moving if (this.moveLock) return; // Discard move requests if we're already moving
@ -134,7 +144,7 @@ export default {
z: this.setPosition.z + z, z: this.setPosition.z + z,
}; };
} }
await this.timeout(1); // Wait for Vue to update the position await this.$nextTick(); // Wait for Vue to update the position
await this.startMoveTask(); await this.startMoveTask();
}, },
async startMoveTask() { async startMoveTask() {

View file

@ -37,11 +37,13 @@
</template> </template>
<script> <script>
import { eventBus } from "../../../eventBus.js";
export default { export default {
name: "StageControlButtons", name: "StageControlButtons",
methods: { methods: {
move(x, y, z) { move(x, y, z) {
this.$root.$emit("globalMoveStepEvent", x, y, z); eventBus.emit("globalMoveStepEvent", { x, y, z, absolute: false });
}, },
}, },
}; };

View file

@ -11,7 +11,7 @@
</template> </template>
<script> <script>
import paneControl from "./controlComponents/paneControl"; import paneControl from "./controlComponents/paneControl.vue";
import streamDisplay from "./streamContent.vue"; import streamDisplay from "./streamContent.vue";
export default { export default {

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-observe-visibility="visibilityChanged" class="uk-padding uk-padding-remove-top"> <div ref="loggingDisplay" class="uk-padding uk-padding-remove-top">
<!-- Logging nav bar --> <!-- Logging nav bar -->
<nav class="logging-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click"> <nav class="logging-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click">
<!-- Left side controls --> <!-- Left side controls -->
@ -81,8 +81,9 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import Paginate from "vuejs-paginate"; import Paginate from "vuejs-paginate-next";
import EndpointButton from "../labThingsComponents/endpointButton.vue"; import EndpointButton from "../labThingsComponents/endpointButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "LoggingContent", name: "LoggingContent",
@ -92,6 +93,8 @@ export default {
EndpointButton, EndpointButton,
}, },
emits: ["scrollTop"],
data: function () { data: function () {
return { return {
maxitems: 20, maxitems: 20,
@ -133,6 +136,18 @@ export default {
}, },
}, },
mounted() {
useIntersectionObserver(
this.$refs.loggingDisplay,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
methods: { methods: {
scrollToTop() { scrollToTop() {
this.$emit("scrollTop"); this.$emit("scrollTop");
@ -183,7 +198,6 @@ export default {
} else { } else {
// if there's no existing log message to append to, discard lines // if there's no existing log message to append to, discard lines
// until we find one. // until we find one.
console.log("Ignored non-matching lines at the start of the log file.");
continue; continue;
} }
} }

View file

@ -1,11 +1,12 @@
<template> <template>
<div v-observe-visibility="visibilityChanged"> <div ref="osdViewerContainer" class="osd-viewer-container uk-height-1-1">
<div id="openseadragon" ref="osdContainer"></div> <div id="openseadragon" ref="osdContainer"></div>
</div> </div>
</template> </template>
<script> <script>
import OpenSeaDragon from "openseadragon"; import OpenSeaDragon from "openseadragon";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "OpenSeadragonViewer", name: "OpenSeadragonViewer",
@ -54,24 +55,41 @@ export default {
}, },
async mounted() { async mounted() {
useIntersectionObserver(
this.$refs.osdViewerContainer,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{ threshold: 0.0 },
);
if (this.src) { if (this.src) {
this.loadOpenSeaDragon(this.src); this.loadOpenSeaDragon(this.src);
} }
}, },
beforeDestroy() { beforeUnmount() {
// Remove global signal listener to perform a gallery refresh // Remove global signal listener to perform a gallery refresh
this.osdViewer.destroy(); if (this.osdViewer) {
this.osdViewer.destroy();
}
}, },
methods: { methods: {
visibilityChanged(isVisible) { visibilityChanged(isVisible) {
// adding this check to avoid error when the viewer is not yet initialized.
if (isVisible) { if (isVisible) {
this.loadOpenSeaDragon(); if (!this.osdViewer && this.src) {
this.loadOpenSeaDragon(this.src);
}
} else { } else {
this.osdViewer.destroy(); if (this.osdViewer) {
this.osdViewer.destroy();
this.osdViewer = null;
}
} }
}, },
async loadOpenSeaDragon() { async loadOpenSeaDragon() {
if (this.osdViewer) { if (this.osdViewer) {
this.osdViewer.destroy(); this.osdViewer.destroy();

View file

@ -7,7 +7,7 @@
id="thumbnail-stitched-image" id="thumbnail-stitched-image"
class="thumbnail-fit" class="thumbnail-fit"
:src="thumbnailPath" :src="thumbnailPath"
onerror="this.src='/titleiconpink.svg';" onerror="this.src = '/titleiconpink.svg'"
@click="requestViewer" @click="requestViewer"
/> />
</div> </div>
@ -86,7 +86,10 @@ import EndpointButton from "../../labThingsComponents/endpointButton.vue";
// Export main app // Export main app
export default { export default {
name: "ScanCard", name: "ScanCard",
components: { actionButton, EndpointButton }, components: {
actionButton,
EndpointButton,
},
props: { props: {
scanData: { scanData: {
@ -103,6 +106,8 @@ export default {
}, },
}, },
emits: ["viewer-requested", "update-requested"],
computed: { computed: {
downloadStitchFile() { downloadStitchFile() {
return `${this.$store.getters.baseUri}/smart_scan/get_stitch/${this.scanData.name}`; return `${this.$store.getters.baseUri}/smart_scan/get_stitch/${this.scanData.name}`;
@ -162,7 +167,6 @@ export default {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.setAttribute("download", filename); link.setAttribute("download", filename);
console.log(link);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
}, },

View file

@ -58,7 +58,9 @@ import OpenSeadragonViewer from "./openSeadragonViewer.vue";
export default { export default {
name: "ScanViewerModal", name: "ScanViewerModal",
components: { OpenSeadragonViewer }, components: {
OpenSeadragonViewer,
},
props: { props: {
selectedScan: { selectedScan: {
type: Object, type: Object,

View file

@ -1,8 +1,5 @@
<template> <template>
<div <div ref="galleryDisplay" class="galleryDisplay uk-padding uk-padding-remove-top">
v-observe-visibility="visibilityChanged"
class="galleryDisplay uk-padding uk-padding-remove-top"
>
<!-- Gallery nav bar --> <!-- Gallery nav bar -->
<nav class="gallery-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click"> <nav class="gallery-navbar uk-navbar-container uk-navbar-transparent" uk-navbar="mode: click">
<!-- Right side buttons --> <!-- Right side buttons -->
@ -100,11 +97,19 @@ import axios from "axios";
import actionButton from "../labThingsComponents/actionButton.vue"; import actionButton from "../labThingsComponents/actionButton.vue";
import scanCard from "./scanListComponents/scanCard.vue"; import scanCard from "./scanListComponents/scanCard.vue";
import ScanViewerModal from "./scanListComponents/scanViewer.vue"; import ScanViewerModal from "./scanListComponents/scanViewer.vue";
import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core";
// Export main app // Export main app
export default { export default {
name: "ScanListContent", name: "ScanListContent",
components: { actionButton, scanCard, ScanViewerModal }, components: {
actionButton,
scanCard,
ScanViewerModal,
},
emits: ["scrollTop"],
data: function () { data: function () {
return { return {
@ -141,13 +146,22 @@ export default {
}, },
async mounted() { async mounted() {
useIntersectionObserver(
this.$refs.galleryDisplay,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0, // Adjust as needed
},
);
// Update on mount (does nothing if not connected) // Update on mount (does nothing if not connected)
await this.updateScans(); await this.updateScans();
// A global signal listener to perform a gallery refresh // A global signal listener to perform a gallery refresh
this.$root.$on("globalUpdateScans", () => { eventBus.on("globalUpdateScans", () => {
this.updateScans(); this.updateScans();
}); });
this.$root.$on("modalClosed", () => { eventBus.on("modalClosed", () => {
// Handle the modal closed event here // Handle the modal closed event here
this.updateScans(); this.updateScans();
}); });
@ -171,15 +185,15 @@ export default {
); );
}, },
beforeDestroy() { beforeUnmount() {
// Remove global signal listener to perform a gallery refresh // Remove global signal listener to perform a gallery refresh
this.$root.$off("globalUpdateScans"); eventBus.off("globalUpdateScans", this.updateScans);
// Then we call that function here to unwatch // Then we call that function here to unwatch
if (this.unwatchStoreFunction) { if (this.unwatchStoreFunction) {
this.unwatchStoreFunction(); this.unwatchStoreFunction();
this.unwatchStoreFunction = null; this.unwatchStoreFunction = null;
} }
this.$root.$off("modalClosed"); // Clean up event listener eventBus.off("modalClosed", this.updateScans); // Clean up event listener
}, },
methods: { methods: {

View file

@ -1,5 +1,5 @@
<template> <template>
<div id="CSMCalibrationSettings" v-observe-visibility="visibilityChanged"> <div id="CSMCalibrationSettings" ref="CSMCalibrationSettingsContainer" class="uk-width-large">
<!--Show auto calibrate if default plugin is enabled--> <!--Show auto calibrate if default plugin is enabled-->
<div v-if="'calibrate_xy' in actions" class="uk-margin-small"> <div v-if="'calibrate_xy' in actions" class="uk-margin-small">
<action-button <action-button
@ -57,6 +57,7 @@
<script> <script>
import ActionButton from "@/components/labThingsComponents/actionButton.vue"; import ActionButton from "@/components/labThingsComponents/actionButton.vue";
import matrixDisplay from "@/components/ui/matrixDisplay.vue"; import matrixDisplay from "@/components/ui/matrixDisplay.vue";
import { useIntersectionObserver } from "@vueuse/core";
// Export main app // Export main app
export default { export default {
@ -75,6 +76,8 @@ export default {
}, },
}, },
emits: ["recalibrateResponse"],
data() { data() {
return { return {
csmMatrix: undefined, csmMatrix: undefined,
@ -93,6 +96,16 @@ export default {
}, },
}, },
mounted() {
useIntersectionObserver(
this.$refs.CSMCalibrationSettingsContainer,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{ threshold: 0.0 },
);
},
methods: { methods: {
visibilityChanged(isVisible) { visibilityChanged(isVisible) {
if (isVisible) { if (isVisible) {

View file

@ -43,6 +43,8 @@ export default {
}, },
}, },
emits: ["actionFinished"],
data() { data() {
return { return {
primaryCalibrationActions: [], primaryCalibrationActions: [],

View file

@ -1,5 +1,5 @@
<template> <template>
<div id="stageSettings" v-observe-visibility="visibilityChanged" class="uk-width-large"> <div id="stageSettings" ref="stageSettingsContainer" class="uk-width-large">
The microscope stage is a <b>{{ stageType }}</b> The microscope stage is a <b>{{ stageType }}</b>
<div> <div>
<div class="uk-margin"> <div class="uk-margin">
@ -29,6 +29,7 @@
<script> <script>
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "StageSettings", name: "StageSettings",
@ -49,6 +50,16 @@ export default {
}, },
}, },
mounted() {
useIntersectionObserver(
this.$refs.stageSettingsContainer,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{ threshold: 0.0 },
);
},
methods: { methods: {
visibilityChanged(isVisible) { visibilityChanged(isVisible) {
if (isVisible) { if (isVisible) {

View file

@ -65,8 +65,9 @@ import cameraSettings from "./settingsComponents/cameraSettings.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
import tabIcon from "../genericComponents/tabIcon"; import tabIcon from "../genericComponents/tabIcon.vue";
import tabContent from "../genericComponents/tabContent"; import tabContent from "../genericComponents/tabContent.vue";
import { markRaw } from "vue";
// Export main app // Export main app
export default { export default {
@ -87,14 +88,14 @@ export default {
id: "display", id: "display",
title: "Display", title: "Display",
requireConnection: false, requireConnection: false,
component: displaySettings, component: markRaw(displaySettings),
requiredThings: [], requiredThings: [],
}, },
{ {
id: "stage-control", id: "stage-control",
title: "Stage Control Preferences", title: "Stage Control Preferences",
requireConnection: false, requireConnection: false,
component: stageControlSettings, component: markRaw(stageControlSettings),
requiredThings: ["stage"], requiredThings: ["stage"],
}, },
], ],
@ -103,21 +104,21 @@ export default {
id: "camera", id: "camera",
title: "Camera", title: "Camera",
requireConnection: true, requireConnection: true,
component: cameraSettings, component: markRaw(cameraSettings),
requiredThings: [], requiredThings: [],
}, },
{ {
id: "stage", id: "stage",
title: "Stage", title: "Stage",
requireConnection: true, requireConnection: true,
component: stageSettings, component: markRaw(stageSettings),
requiredThings: ["stage"], requiredThings: ["stage"],
}, },
{ {
id: "mapping", id: "mapping",
title: "Camera to Stage Mapping", title: "Camera to Stage Mapping",
requireConnection: true, requireConnection: true,
component: CSMSettings, component: markRaw(CSMSettings),
requiredThings: ["camera_stage_mapping"], requiredThings: ["camera_stage_mapping"],
}, },
], ],

View file

@ -1,7 +1,7 @@
<template> <template>
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <div 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" v-observe-visibility="visibilityChanged" class="uk-padding-small"> <div v-show="!scanning" ref="slideScanView" class="uk-padding-small">
<!-- Workflow Selection Dropdown --> <!-- Workflow Selection Dropdown -->
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label">Workflow</label> <label class="uk-form-label">Workflow</label>
@ -65,8 +65,8 @@
:submit-data="{ scan_name: scan_name }" :submit-data="{ scan_name: scan_name }"
submit-label="Start Smart Scan" submit-label="Start Smart Scan"
:can-terminate="true" :can-terminate="true"
@taskStarted="startScanning" @task-started="startScanning"
@update:taskStatus="taskStatus = $event" @update:task-status="taskStatus = $event"
@update:progress="progress = $event" @update:progress="progress = $event"
@update:log="log = $event" @update:log="log = $event"
/> />
@ -130,6 +130,7 @@ import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue"; import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue"; import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
import ActionButton from "../labThingsComponents/actionButton.vue"; import ActionButton from "../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "SlideScanContent", name: "SlideScanContent",
@ -137,11 +138,11 @@ export default {
components: { components: {
streamDisplay, streamDisplay,
propertyControl, propertyControl,
ServerSpecifiedPropertyControl,
actionLogDisplay, actionLogDisplay,
actionProgressBar, actionProgressBar,
MiniStreamDisplay, MiniStreamDisplay,
ActionButton, ActionButton,
ServerSpecifiedPropertyControl,
}, },
data() { data() {
@ -174,7 +175,23 @@ export default {
async created() { async created() {
this.readSettings(); this.readSettings();
this.workflowOptions = await this.readThingProperty("smart_scan", "workflow_display_names"); this.workflowOptions = await this.readThingProperty(
"smart_scan",
"workflow_display_names",
true,
);
},
mounted() {
useIntersectionObserver(
this.$refs.slideScanView,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
}, },
methods: { methods: {
@ -185,12 +202,22 @@ export default {
}, },
async readSettings() { async readSettings() {
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name"); this.workflowName = await this.readThingProperty("smart_scan", "workflow_name");
if (!this.workflowName) {
console.warn("Could not read workflow_name, using default");
this.workflowName = "histo_scan_workflow";
}
if (this.workflowName) { if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready"); this.ready = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings = await this.readThingProperty(this.workflowName, "settings_ui"); this.workflowSettings =
console.log(this.workflowSettings); (await this.readThingProperty(this.workflowName, "settings_ui", true)) || [];
this.workflowDisplayName = await this.readThingProperty(this.workflowName, "display_name"); this.workflowDisplayName = await this.readThingProperty(
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb"); this.workflowName,
"display_name",
true,
);
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb", true);
} }
}, },
onScanError: function (error) { onScanError: function (error) {
@ -224,11 +251,12 @@ export default {
async pollScan() { async pollScan() {
if (this.cancellable) { if (this.cancellable) {
// while the scan is running // while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time"); let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true);
if (mtime !== null) { if (mtime !== null) {
this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`; this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
} }
this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name");
this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name", true);
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
} }
}, },
@ -244,7 +272,7 @@ export default {
this.modalError(err); this.modalError(err);
// revert if server rejected // revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name"); this.workflowName = await this.readThingProperty("smart_scan", "workflow_name", true);
} }
}, },
async downloadZipFile(response) { async downloadZipFile(response) {
@ -254,7 +282,6 @@ export default {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.setAttribute("download", filename); link.setAttribute("download", filename);
console.log(link);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
}, },

View file

@ -2,7 +2,6 @@
<div <div
id="stream-display" id="stream-display"
ref="streamDisplay" ref="streamDisplay"
v-observe-visibility="visibilityChanged"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget" class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
> >
<img <img
@ -35,6 +34,9 @@
</template> </template>
<script> <script>
import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core";
// Export main app // Export main app
export default { export default {
name: "StreamDisplay", name: "StreamDisplay",
@ -62,30 +64,42 @@ export default {
}, },
mounted() { mounted() {
//set up an intersection observer
useIntersectionObserver(
this.$refs.streamDisplay,
([{ isIntersecting }]) => {
this.isVisible = isIntersecting;
},
{
threshold: 0.0,
},
);
// A global signal listener to flash the stream element // A global signal listener to flash the stream element
this.$root.$on("globalFlashStream", () => { this.onFlashStream = () => {
this.flashStream(); this.flashStream();
}); };
eventBus.on("globalFlashStream", this.onFlashStream);
// Mutation observer // Mutation observer
this.sizeObserver = new ResizeObserver(() => { this.sizeObserver = new ResizeObserver(() => {
this.handleResize(); // For any element attached to the observer, run handleResize() on change this.handleResize(); // For any element attached to the observer, run handleResize() on change
}); });
// Fetch streamDisplay component by ref // Fetch streamDisplay component by ref
const streamDisplayElement = this.$refs.streamDisplay.parentNode; if (this.$refs.streamDisplay && this.$refs.streamDisplay.parentNode) {
// Attach streamDisplay to the size observer const streamDisplayElement = this.$refs.streamDisplay.parentNode;
this.sizeObserver.observe(streamDisplayElement); // Attach streamDisplay to the size observer
this.sizeObserver.observe(streamDisplayElement);
}
}, },
created: function () { created: function () {
// Do nothing: preview stream now runs all the time // Do nothing: preview stream now runs all the time
}, },
beforeDestroy: function () { beforeUnmount: function () {
// Remove global signal listener to change the GPU preview state // Remove global signal listener to change the GPU preview state
this.$root.$off("globalTogglePreview"); eventBus.off("globalTogglePreview", true);
// Remove global signal listener to flash the stream element
this.$root.$off("globalFlashStream");
// Disconnect the size observer // Disconnect the size observer
this.sizeObserver.disconnect(); this.sizeObserver.disconnect();
// Remove from the array of active streams // Remove from the array of active streams
@ -93,9 +107,6 @@ export default {
}, },
methods: { methods: {
visibilityChanged(isVisible) {
this.isVisible = isVisible;
},
flashStream: function () { flashStream: function () {
// Run an animation that flashes the stream (for capture feedback) // Run an animation that flashes the stream (for capture feedback)
let element = this.$refs.streamDisplay; let element = this.$refs.streamDisplay;
@ -126,7 +137,10 @@ export default {
let yRelative = (0.5 * event.target.offsetHeight - yCoordinate) * scale; let yRelative = (0.5 * event.target.offsetHeight - yCoordinate) * scale;
// Emit a signal to move, acted on by paneControl.vue // Emit a signal to move, acted on by paneControl.vue
this.$root.$emit("globalMoveInImageCoordinatesEvent", -xRelative, -yRelative); eventBus.emit("globalMoveInImageCoordinatesEvent", {
x: -xRelative,
y: -yRelative,
});
}, },
handleResize: function () { handleResize: function () {

3
webapp/src/eventBus.js Normal file
View file

@ -0,0 +1,3 @@
import mitt from "mitt";
export const eventBus = mitt();

View file

@ -1,6 +1,5 @@
// Generic functions for formatting // Generic functions for formatting
/** /**
* Format a single number into a human-readable string. * Format a single number into a human-readable string.
* Uses exponential notation for very large/small numbers. * Uses exponential notation for very large/small numbers.
@ -30,7 +29,7 @@ export function formatNumber(num, maxDigits = 4) {
*/ */
export function formatValue(value, maxDigits = 4) { export function formatValue(value, maxDigits = 4) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const items = value.map(val => formatValue(val, maxDigits)); const items = value.map((val) => formatValue(val, maxDigits));
return `[${items.join(", ")}]`; return `[${items.join(", ")}]`;
} }
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
@ -47,4 +46,4 @@ export function formatValue(value, maxDigits = 4) {
} }
// Only if the string will not coerce to a number do we output the value. // Only if the string will not coerce to a number do we output the value.
return value; return value;
} }

View file

@ -1,15 +1,13 @@
import Vue from "vue"; import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import store from "./store"; import store from "./store";
import UIkit from "uikit"; import UIkit from "uikit";
import VueObserveVisibility from "vue-observe-visibility";
// Import MD icons // Import MD icons
import "material-symbols/outlined.css"; import "material-symbols/outlined.css";
import queryMixin from "@/mixins/labThingsMixins.js";
import modalMixin from "@/mixins/modalMixins.js"; import modalMixin from "@/mixins/modalMixins.js";
import labThingsMixins from "./mixins/labThingsMixins";
// UIKit overrides // UIKit overrides
UIkit.mixin( UIkit.mixin(
@ -21,15 +19,16 @@ UIkit.mixin(
"accordion", "accordion",
); );
// Create Vue app
const app = createApp(App);
// Use visibility observer // Use visibility observer
Vue.use(VueObserveVisibility); //app.use(VueObserveVisibility);
Vue.config.productionTip = false; // Use global mixins
app.mixin(modalMixin);
app.mixin(labThingsMixins);
Vue.mixin(queryMixin); // Use Vuex store
Vue.mixin(modalMixin); app.use(store);
app.mount("#app");
new Vue({
store,
render: (h) => h(App),
}).$mount("#app");

View file

@ -59,7 +59,7 @@ export default {
// `false` fails because axios somehow eats it! // `false` fails because axios somehow eats it!
// Other values should not be stringified or pydantic // Other values should not be stringified or pydantic
// can't parse them. // can't parse them.
if ((value === false) | (value === true)) { if (value === false || value === true) {
value = JSON.stringify(value); value = JSON.stringify(value);
} }
await axios.put(url, value); await axios.put(url, value);
@ -91,7 +91,7 @@ export default {
response = await axios.get(taskUrl, { baseURL: this.$store.getters.baseUri }); response = await axios.get(taskUrl, { baseURL: this.$store.getters.baseUri });
const result = response.data.status; const result = response.data.status;
if ((result == "running") | (result == "pending")) { if (result === "running" || result === "pending") {
ongoingMethod?.(response); ongoingMethod?.(response);
this.pollTimers[taskUrl] = setTimeout(() => { this.pollTimers[taskUrl] = setTimeout(() => {
this.pollUntilComplete(taskUrl, ongoingMethod, finalMethod, interval); this.pollUntilComplete(taskUrl, ongoingMethod, finalMethod, interval);

View file

@ -7,6 +7,7 @@
*/ */
import UIkit from "uikit"; import UIkit from "uikit";
import { eventBus } from "@/eventBus";
export default { export default {
methods: { methods: {
@ -14,7 +15,7 @@ export default {
var context = this; var context = this;
// Stop GPU preview to show modal // Stop GPU preview to show modal
context.$root.$emit("globalTogglePreview", false); eventBus.emit("globalTogglePreview", false);
// force OK to be capitalised // force OK to be capitalised
UIkit.modal.i18n = { ok: "OK", cancel: "Cancel" }; UIkit.modal.i18n = { ok: "OK", cancel: "Cancel" };
@ -33,9 +34,9 @@ export default {
.finally(function () { .finally(function () {
// Re-enable the GPU preview, if it was active before the modal // Re-enable the GPU preview, if it was active before the modal
if (context.$store.state.autoGpuPreview) { if (context.$store.state.autoGpuPreview) {
context.$root.$emit("globalTogglePreview", true); eventBus.emit("globalTogglePreview", true);
} }
context.$root.$emit("modalClosed"); eventBus.emit("modalClosed");
}); });
}; };
return new Promise(showModal); return new Promise(showModal);

View file

@ -1,9 +1,6 @@
import Vue from "vue"; import { createStore } from "vuex";
import Vuex from "vuex";
import wotStoreModule from "./wot-client"; import wotStoreModule from "./wot-client";
Vue.use(Vuex);
function getOriginFromLocation() { function getOriginFromLocation() {
// This will default to the same origin that's serving // This will default to the same origin that's serving
// the web app - but can be overridden by the URL. // the web app - but can be overridden by the URL.
@ -55,7 +52,7 @@ const LOCALSTORAGE_KEYS = [
"navigationInvert", "navigationInvert",
]; ];
export default new Vuex.Store({ export default createStore({
modules: { modules: {
wot: wotStoreModule, wot: wotStoreModule,
}, },
@ -150,7 +147,6 @@ export default new Vuex.Store({
(store) => { (store) => {
// Load initial state from localStorage // Load initial state from localStorage
LOCALSTORAGE_KEYS.forEach((key) => { LOCALSTORAGE_KEYS.forEach((key) => {
console.log(key);
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved !== null) { if (saved !== null) {
try { try {

View file

@ -28,7 +28,7 @@ export const wotStoreModule = {
// Deduplication should be done elsewhere. // Deduplication should be done elsewhere.
let response = await axios.get(uri); let response = await axios.get(uri);
let td = response.data; let td = response.data;
let thing_name = name | uri.replace(/\/$/, "").split("/").pop(); let thing_name = name || uri.replace(/\/$/, "").split("/").pop();
commit("addThingDescription", { commit("addThingDescription", {
thingName: thing_name, thingName: thing_name,
thingDescription: td, thingDescription: td,
@ -37,7 +37,7 @@ export const wotStoreModule = {
async fetchThingDescriptions({ commit }, uri) { async fetchThingDescriptions({ commit }, uri) {
// Fetch thing descriptions from the given URI // Fetch thing descriptions from the given URI
let response = await axios.get(uri); let response = await axios.get(uri);
if (response.status != 200) throw "Could not retrieve thing descriptions"; if (response.status !== 200) throw "Could not retrieve thing descriptions";
for (const k in response.data) { for (const k in response.data) {
let thing_name = k.replace(/\/$/, "").replace(/^\//, ""); let thing_name = k.replace(/\/$/, "").replace(/^\//, "");
commit("addThingDescription", { commit("addThingDescription", {
@ -77,8 +77,14 @@ export const wotStoreModule = {
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`; throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
} }
let affordances = td[affordanceType]; let affordances = td[affordanceType];
if (!affordances || !(affordance in affordances)) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
let href = findFormHref(affordances[affordance], op); let href = findFormHref(affordances[affordance], op);
if (href == undefined) { if (href === undefined) {
if (allowUndefined) return undefined; if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`; throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
} }
@ -105,10 +111,10 @@ export const wotStoreModule = {
export function findFormHref(affordance, op) { export function findFormHref(affordance, op) {
// Find the form in the affordance that matches the given operation type // Find the form in the affordance that matches the given operation type
if (affordance == undefined) return undefined; if (affordance === undefined) return undefined;
let forms = affordance.forms; let forms = affordance.forms;
let matchingForm = forms.find((f) => f.op == op || f.op.includes(op)); let matchingForm = forms.find((f) => f.op == op || f.op.includes(op));
if (matchingForm == undefined) return undefined; if (matchingForm === undefined) return undefined;
return matchingForm.href; return matchingForm.href;
} }

52
webapp/vite.config.mjs Normal file
View file

@ -0,0 +1,52 @@
// vite.config.mjs
// Vite replaces Vue CLI as the build tool for Vue 3.
// Note: Comments are generated to explain relevant configuration options.
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
build: {
// Output directory for production builds
outDir: "../src/openflexure_microscope_server/static",
// Clear output directory before build
emptyOutDir: true,
},
resolve: {
alias: {
// Setup path alias for src directory.
// This allows importing modules using '@/path/to/module'.
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
// Recognize these file extensions for module resolution.
extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
},
server: {
host: true,
// Set the development server port to 8080.
// OFM uses port 5000, so we avoid conflicts. run, and override by using the address:
// http://microscope.local:8080/?overrideOrigin=http://microscope.local:5000#
port: 8080,
strictPort: true,
},
css: {
preprocessorOptions: {
less: {
// Always include math in Less files.
math: "always",
// Enable relative URLs in Less files.
relativeUrls: true,
// Enable JavaScript in Less files
javascriptEnabled: true,
},
},
},
});

View file

@ -1,3 +0,0 @@
module.exports = {
outputDir: "../src/openflexure_microscope_server/static",
};