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
import logging
import os
from argparse import Namespace
from copy import copy
from functools import wraps
from typing import Any, Callable, Optional
import uvicorn
from fastapi.middleware.cors import CORSMiddleware # vue3 migration
from uvicorn.main import Server
import labthings_fastapi as lt
@ -28,6 +30,7 @@ from .legacy_api import add_v2_endpoints
from .serve_static_files import add_static_files
LOGGER = logging.getLogger(__name__)
DEVELOPER_MODE = os.getenv("OFM_SERVER_DEV_MODE", "false").lower() == "true"
def set_shutdown_function(shutdown_function: Callable[[], None]) -> None:
@ -61,6 +64,17 @@ def customise_server(
) -> None:
"""Customise the server with additional endpoints, etc."""
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_static_files(server.app, scans_folder)

View file

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

View file

@ -18,8 +18,7 @@ def mock_static_dir():
It contains enough files to be recognised as a webapp.
"""
with tempfile.TemporaryDirectory() as tmpdir:
os.makedirs(os.path.join(tmpdir, "js"))
os.makedirs(os.path.join(tmpdir, "css"))
os.makedirs(os.path.join(tmpdir, "assets"))
with open(os.path.join(tmpdir, "index.html"), "w", encoding="utf-8") as f_obj:
f_obj.write("<mock>mock</mock>")
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.
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")
# Remove javascript dir and check error.
shutil.rmtree(js_path)
# Remove asset dir and check error.
shutil.rmtree(asset_path)
with pytest.raises(FileNotFoundError):
serve_static_files.check_static_dir()
# replace javascript dir with file, it should still error.
shutil.copyfile(index_path, js_path)
# replace asset dir with file, it should still error.
shutil.copyfile(index_path, asset_path)
with pytest.raises(FileNotFoundError):
serve_static_files.check_static_dir()
# Return it to a folder and check everything works again
os.remove(js_path)
os.makedirs(js_path)
os.remove(asset_path)
os.makedirs(asset_path)
serve_static_files.check_static_dir()
# 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"
# Also should have mounted both dirs
assert mock_app.mount.call_count == 2
mounted_paths = [call.args[0] for call in mock_app.mount.call_args_list]
mounted_dirs = [call.args[1].directory for call in mock_app.mount.call_args_list]
assert "/css/" in mounted_paths
assert "/js/" in mounted_paths
assert os.path.join(mock_static_dir, "css") in mounted_dirs
assert os.path.join(mock_static_dir, "js") in mounted_dirs
assert mock_app.mount.call_count == 1
mounted_path = mock_app.mount.call_args.args[0]
mounted_dir = mock_app.mount.call_args.args[1].directory
assert "/assets/" in mounted_path
assert os.path.join(mock_static_dir, "assets") in mounted_dir
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_PLATFORM=web
VUE_APP_TARGET=web

View file

@ -1,23 +1,44 @@
module.exports = {
root: true,
env: {
node: true,
es2021: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@babel/eslint-parser",
requireConfigFile: false,
ecmaVersion: 2020,
ecmaVersion: 2021,
sourceType: "module",
},
extends: ["plugin:vue/recommended", "eslint:recommended", "@vue/prettier"],
extends: ["plugin:vue/vue3-recommended", "eslint:recommended", "@vue/prettier"],
rules: {
"no-console": 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)",
"scripts": {
"gv": "genversion src/version.js",
"serve": "vue-cli-service serve --mode development",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --no-fix --max-warnings 0",
"lint:fix": "vue-cli-service lint --fix"
"serve": "vite serve --mode development",
"build": "vite build --mode production",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore",
"lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
"axios": "^1.7.7",
"material-design-icons": "^3.0",
"mitt": "^3.0.1",
"mousetrap": "^1.6.5",
"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": {
"@babel/eslint-parser": "^7.28.5",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-router": "^5.0.9",
"@vue/cli-plugin-vuex": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/eslint-config-prettier": "^7.1.0",
"axios": "^1.13.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-prettier": "9.0.0",
"autoprefixer": "^10.4.24",
"core-js": "^3.46.0",
"css-loader": "^3.6",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.10.2",
"eslint-plugin-prettier": "^3.4.1",
"eslint-plugin-vue": "^7.20.0",
"less": "^4.4.2",
"less-loader": "^11.1.4",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.0",
"eslint-plugin-vue": "^9.20.0",
"less": "^4.5.1",
"material-symbols": "^0.39.1",
"prettier": "^2.8.8",
"uikit": "^3.24.2",
"vue": "^2.7",
"vue-template-compiler": "^2.7",
"vuejs-paginate": "^2.1",
"vuex": "^3.6"
"prettier": "^3.2.8",
"vite": "^5.4.21"
},
"postcss": {
"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 appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue";
var Mousetrap = require("mousetrap");
import Mousetrap from "mousetrap";
import { eventBus } from "./eventBus.js";
Mousetrap.prototype.stopCallback = function (e, element) {
// 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;
}
@ -153,10 +153,10 @@ export default {
// Focus keys
Mousetrap.bind("pageup", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, 1);
eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: 1 });
});
Mousetrap.bind("pagedown", () => {
this.$root.$emit("globalMoveStepEvent", 0, 0, -1);
eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: -1 });
});
this.keyboardManual.push({
shortcut: "pgup / pgdn",
@ -165,7 +165,7 @@ export default {
// Capture
Mousetrap.bind("c", () => {
this.$root.$emit("globalCaptureEvent");
eventBus.emit("globalCaptureEvent", {});
});
this.keyboardManual.push({
shortcut: "c",
@ -174,7 +174,7 @@ export default {
// Autofocus
Mousetrap.bind("a", () => {
this.$root.$emit("globalFastAutofocusEvent");
eventBus.emit("globalFastAutofocusEvent", {});
});
this.keyboardManual.push({
shortcut: "a",
@ -183,10 +183,10 @@ export default {
// Increment/decrement tab
Mousetrap.bind("shift+down", () => {
this.$root.$emit("globalIncrementTab");
eventBus.emit("globalIncrementTab", {});
});
Mousetrap.bind("shift+up", () => {
this.$root.$emit("globalDecrementTab");
eventBus.emit("globalDecrementTab", {});
});
this.keyboardManual.push({
shortcut: "shift+↑ / shift+↓",
@ -194,7 +194,7 @@ export default {
});
},
beforeDestroy: function () {
beforeUnmount: function () {
// Disconnect the theme observer
if (this.themeObserver) {
this.themeObserver.disconnect();
@ -237,7 +237,7 @@ export default {
}
},
handleExit: function () {
this.$root.$emit("globalTogglePreview", false);
eventBus.emit("globalTogglePreview", false);
},
// Handle global mouse wheel events to be associated with navigation
@ -247,9 +247,14 @@ export default {
event.target.parentNode.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
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
var x_rel = 0;
var y_rel = 0;
var z_rel = 0;
// 37 corresponds to the left key
if (37 in this.arrowKeysDown) {
x_rel = x_rel - 1;
@ -276,7 +280,7 @@ export default {
}
// Make a position request
// 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>
<div id="app-content" class="uk-margin-remove uk-padding-remove uk-height-1-1" uk-grid>
<!-- Initialisation modals -->
<calibrationWizard ref="calibrationWizard" @onClose="enterApp()"></calibrationWizard>
<calibrationWizard ref="calibrationWizard" @on-close="enterApp()"></calibrationWizard>
<!-- Vertical tab bar -->
<div id="switcher-left-container">
<div
@ -9,11 +9,10 @@
class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center"
>
<!-- 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 -->
<tabIcon
:id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'"
:tab-i-d="item.id"
:title="item.title"
:require-connection="true"
@ -37,11 +36,10 @@
<hr id="extension-tab-divider" />
<!-- 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 -->
<tabIcon
:id="item.id + '-tab-icon'"
:key="item.id + '-tab-icon'"
:tab-i-d="item.id"
:title="item.title"
:require-connection="true"
@ -71,7 +69,7 @@
:require-connection="true"
:current-tab="currentTab"
>
<component :is="item.component" @scrollTop="scrollToTop"></component>
<component :is="item.component" @scroll-top="scrollToTop"></component>
</tabContent>
</div>
</div>
@ -79,8 +77,8 @@
<script>
// Import generic components
import tabIcon from "./genericComponents/tabIcon";
import tabContent from "./genericComponents/tabContent";
import tabIcon from "./genericComponents/tabIcon.vue";
import tabContent from "./genericComponents/tabContent.vue";
// Import new content components
import aboutContent from "./tabContentComponents/aboutContent.vue";
@ -92,6 +90,8 @@ 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 { markRaw } from "vue";
import { eventBus } from "../eventBus.js";
// Import modal components for device initialisation
import calibrationWizard from "./modalComponents/calibrationWizard.vue";
@ -113,26 +113,26 @@ export default {
id: "settings",
title: "Settings",
icon: "settings",
component: settingsContent,
component: markRaw(settingsContent),
class: "uk-margin-auto-top",
},
{
id: "logging",
title: "Logging",
icon: "assignment_late",
component: loggingContent,
component: markRaw(loggingContent),
},
{
id: "about",
title: "About",
icon: "info",
component: aboutContent,
component: markRaw(aboutContent),
},
{
id: "power",
title: "Power",
icon: "power_settings_new",
component: powerContent,
component: markRaw(powerContent),
},
],
coreTopTabs: [
@ -140,21 +140,21 @@ export default {
id: "view",
title: "View",
icon: "visibility",
component: viewContent,
component: markRaw(viewContent),
requiredThings: [],
},
{
id: "control",
title: "Control",
icon: "gamepad",
component: controlContent,
component: markRaw(controlContent),
requiredThings: [],
},
{
id: "background-detect",
title: "Background Detect",
icon: "background_replace",
component: backgroundDetectContent,
component: markRaw(backgroundDetectContent),
// While stage isn't needed; automatic background detect has little function
// for a manual microscope.
requiredThings: ["stage"],
@ -163,14 +163,14 @@ export default {
id: "slide-scan",
title: "Slide Scan",
icon: "settings_overscan",
component: slideScanContent,
component: markRaw(slideScanContent),
requiredThings: ["smart_scan"],
},
{
id: "scan-list",
title: "Scan List",
icon: "photo_library",
component: scanListContent,
component: markRaw(scanListContent),
requiredThings: ["smart_scan"],
},
],
@ -207,15 +207,15 @@ export default {
mounted() {
// A global signal listener to switch tab
this.$root.$on("globalSwitchTab", (tabID) => {
eventBus.on("globalSwitchTab", (tabID) => {
this.currentTab = tabID;
});
// A global signal listener to increment tab
this.$root.$on("globalIncrementTab", () => {
eventBus.on("globalIncrementTab", () => {
this.incrementTabBy(1);
});
// A global signal listener to decrement tab
this.$root.$on("globalDecrementTab", () => {
eventBus.on("globalDecrementTab", () => {
this.incrementTabBy(-1);
});
if (this.$store.getters.ready) {

View file

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

View file

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

View file

@ -1,5 +1,11 @@
<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>
<div v-if="showTitle" class="tabtitle">
{{ computedTitle }}
@ -43,6 +49,8 @@ export default {
requireConnection: Boolean,
},
emits: ["set-tab"],
computed: {
computedTitle: function () {
if (this.title !== undefined) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,8 @@
<script>
export default {
name: "SyncPropertyButton",
emits: ["click"],
};
</script>
@ -26,7 +28,9 @@ export default {
.material-symbols-outlined.sync-icon {
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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -14,10 +14,14 @@
import calibrationWizardTask from "./calibrationWizardTask.vue";
import camCalibrationExplanation from "./cameraCalibrationSteps/camCalibrationExplanation.vue";
import cameraMainCalibrationStep from "./cameraCalibrationSteps/cameraMainCalibrationStep.vue";
import { markRaw } from "vue";
export default {
name: "CameraCalibrationTask",
components: { calibrationWizardTask },
components: {
calibrationWizardTask,
},
props: {
// Standard calibrationWizardTask props below:
first: Boolean,
@ -28,9 +32,14 @@ export default {
},
},
emits: ["next", "back"],
data: function () {
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 focusStep from "./csmSteps/focusStep.vue";
import runCsmStep from "./csmSteps/runCsmStep.vue";
import { markRaw } from "vue";
export default {
name: "CameraCalibrationTask",
components: { calibrationWizardTask },
components: {
calibrationWizardTask,
},
props: {
// Standard calibrationWizardTask props below:
first: Boolean,
@ -28,10 +31,15 @@ export default {
default: false,
},
},
emits: ["next", "back"],
data: function () {
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>
import stepTemplateWithStream from "../stepTemplateWithStream.vue";
import ActionButton from "../../../labThingsComponents/actionButton.vue";
export default {
name: "CameraMainCalibrationStep",
@ -47,7 +48,7 @@ export default {
gap: 8px; /* Small space between buttons */
margin-top: 4px; /* Gap from image */
}
.moveZ >>> .uk-button.uk-width-1-1 {
.moveZ :deep(.uk-button.uk-width-1-1) {
line-height: 50px;
font-size: 50px !important;
height: 60px;

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<template>
<div v-observe-visibility="visibilityChanged" class="uk-padding-small">
<div ref="backgroundDetectContent" class="uk-padding-small">
<div>
<ul uk-accordion="multiple: true">
<li>
@ -47,6 +47,7 @@
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
components: {
@ -65,13 +66,35 @@ export default {
},
async created() {
this.readSettings();
await this.readSettings();
},
mounted() {
useIntersectionObserver(
this.$refs.backgroundDetectContent,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
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) {
if (isVisible) {
this.readSettings();
this.safeReadSettings();
}
},
alertBackgroundSet() {

View file

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

View file

@ -59,7 +59,6 @@ export default {
let imageUri = response.output.href;
if (!imageUri) {
this.modalError("No image URI returned from capture task.");
console.log(`Capture resulted in response ${response}`);
return;
}
// 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 syncPropertyButton from "../../labThingsComponents/syncPropertyButton.vue";
import stageControlButtons from "./stageControlButtons.vue";
import { eventBus } from "../../../eventBus.js";
export default {
name: "PaneControl",
@ -84,40 +86,48 @@ export default {
},
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);
eventBus.on("globalMoveEvent", this.move);
// 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
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
eventBus.on("globalMoveInImageCoordinatesEvent", this.onMoveImage);
// 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);
});
eventBus.on("globalMoveStepEvent", this.onMoveStep);
// Update the current position in text boxes
await this.updatePosition();
},
beforeDestroy() {
beforeUnmount() {
// 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");
eventBus.off("globalMoveEvent", this.move);
eventBus.off("globalUpdatePositionEvent", this.updatePosition);
eventBus.off("globalMoveInImageCoordinatesEvent", this.onMoveImage);
eventBus.off("globalMoveStepEvent", this.onMoveStep);
},
methods: {
timeout(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
// This is equivalent to clicking the "move" button.
if (this.moveLock) return; // Discard move requests if we're already moving
@ -134,7 +144,7 @@ export default {
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();
},
async startMoveTask() {

View file

@ -37,11 +37,13 @@
</template>
<script>
import { eventBus } from "../../../eventBus.js";
export default {
name: "StageControlButtons",
methods: {
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>
<script>
import paneControl from "./controlComponents/paneControl";
import paneControl from "./controlComponents/paneControl.vue";
import streamDisplay from "./streamContent.vue";
export default {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<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>
<div>
<div class="uk-margin">
@ -29,6 +29,7 @@
<script>
import ActionButton from "../../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "StageSettings",
@ -49,6 +50,16 @@ export default {
},
},
mounted() {
useIntersectionObserver(
this.$refs.stageSettingsContainer,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{ threshold: 0.0 },
);
},
methods: {
visibilityChanged(isVisible) {
if (isVisible) {

View file

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

View file

@ -1,7 +1,7 @@
<template>
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<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 -->
<div class="uk-margin">
<label class="uk-form-label">Workflow</label>
@ -65,8 +65,8 @@
:submit-data="{ scan_name: scan_name }"
submit-label="Start Smart Scan"
:can-terminate="true"
@taskStarted="startScanning"
@update:taskStatus="taskStatus = $event"
@task-started="startScanning"
@update:task-status="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
@ -130,6 +130,7 @@ import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
import ActionButton from "../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "SlideScanContent",
@ -137,11 +138,11 @@ export default {
components: {
streamDisplay,
propertyControl,
ServerSpecifiedPropertyControl,
actionLogDisplay,
actionProgressBar,
MiniStreamDisplay,
ActionButton,
ServerSpecifiedPropertyControl,
},
data() {
@ -174,7 +175,23 @@ export default {
async created() {
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: {
@ -185,12 +202,22 @@ export default {
},
async readSettings() {
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) {
this.ready = await this.readThingProperty(this.workflowName, "ready");
this.workflowSettings = await this.readThingProperty(this.workflowName, "settings_ui");
console.log(this.workflowSettings);
this.workflowDisplayName = await this.readThingProperty(this.workflowName, "display_name");
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb");
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings =
(await this.readThingProperty(this.workflowName, "settings_ui", true)) || [];
this.workflowDisplayName = await this.readThingProperty(
this.workflowName,
"display_name",
true,
);
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb", true);
}
},
onScanError: function (error) {
@ -224,11 +251,12 @@ export default {
async pollScan() {
if (this.cancellable) {
// 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) {
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
}
},
@ -244,7 +272,7 @@ export default {
this.modalError(err);
// 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) {
@ -254,7 +282,6 @@ export default {
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
console.log(link);
document.body.appendChild(link);
link.click();
},

View file

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

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
/**
* Format a single number into a human-readable string.
* Uses exponential notation for very large/small numbers.
@ -30,7 +29,7 @@ export function formatNumber(num, maxDigits = 4) {
*/
export function formatValue(value, maxDigits = 4) {
if (Array.isArray(value)) {
const items = value.map(val => formatValue(val, maxDigits));
const items = value.map((val) => formatValue(val, maxDigits));
return `[${items.join(", ")}]`;
}
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.
return value;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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