Merge branch 'jogging' into 'v3'

Generalised Jogging

Closes #680

See merge request openflexure/openflexure-microscope-server!476
This commit is contained in:
Joe Knapper 2026-02-18 16:20:42 +00:00
commit 3f7de554d9
10 changed files with 906 additions and 206 deletions

View file

@ -29,6 +29,8 @@ import loadingContent from "./components/loadingContent.vue";
import Mousetrap from "mousetrap";
import { eventBus } from "./eventBus.js";
const move_keys = ["up", "down", "left", "right", "pageup", "pagedown"];
Mousetrap.prototype.stopCallback = function (e, element) {
// if the element has the class "mousetrap" then no need to stop
if ((" " + element.className + " ").indexOf(" Mousetrap ") > -1) {
@ -65,6 +67,10 @@ export default {
keyboardManual: [],
systemDark: undefined,
themeObserver: undefined,
keysDown: new Set(),
lastJogTime: 0,
jogDistance: 600,
jogTime: 300,
};
},
@ -130,34 +136,31 @@ export default {
this.toggleModalElement(this.$refs["keyboardManualModal"]); // Calls the mixin
});
// Arrow keys
Mousetrap.bind(
["up", "down", "left", "right"],
(event) => {
this.arrowKeysDown[event.keyCode] = true; //Add key to array
this.navigateKeyHandler();
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.add(key);
this.updateJogFromKeys();
},
"keydown",
);
Mousetrap.bind(
["up", "down", "left", "right"],
(event) => {
delete this.arrowKeysDown[event.keyCode]; //Remove key from array
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.delete(key);
this.updateJogFromKeys();
},
"keyup",
);
this.keyboardManual.push({
shortcut: "←↑→↓",
description: "Move the microscope stage",
});
// Focus keys
Mousetrap.bind("pageup", () => {
eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: 1 });
});
Mousetrap.bind("pagedown", () => {
eventBus.emit("globalMoveStepEvent", { x: 0, y: 0, z: -1 });
});
this.keyboardManual.push({
shortcut: "pgup / pgdn",
description: "Move the microscope focus",
@ -240,47 +243,85 @@ export default {
eventBus.emit("globalTogglePreview", false);
},
// Handle global mouse wheel events to be associated with navigation
/**
* Handle global mouse wheel events to be associated with navigation
*/
wheelMonitor: function (event) {
// Only capture scroll if the event target's parent contains the "scrollTarget" class
if (
event.target.parentNode.classList.contains("scrollTarget") ||
event.target.classList.contains("scrollTarget")
) {
const z_steps = event.deltaY / 100;
const z_rel = event.deltaY / 100;
// Emit a signal to move, acted on by panelControl.vue
eventBus.emit("globalMoveStepEvent", {
x_steps: 0,
y_steps: 0,
z_steps: z_steps,
absolute: false,
});
const navigationStepSize = this.$store.state.navigationStepSize;
const z = z_rel * navigationStepSize.z;
// Don't use `jog() due to variable size of jogs here and the rate limiting in
// `jog()`. No need to invert on z, as navigationInvert.z isn't exposed.
this.invokeAction("stage", "jog", { x: 0, y: 0, z: z });
eventBus.emit("globalUpdatePositionEvent");
}
},
navigateKeyHandler: function () {
// Calculate movement array
var x_rel = 0;
var y_rel = 0;
// 37 corresponds to the left key
if (37 in this.arrowKeysDown) {
x_rel = x_rel - 1;
/**
* Jog for key-presses.
*
* This is a similar to the function in stageControlButtons.vue however it uses
* uses the key repeat to fire in case a key up is missed. It debounces any
* request to jog that is too recent after the last jog.
*/
jog(x, y, z) {
// Manually debounce extra requests from keyboard repeat rate.
// This is used rather than an interval in case of missing a repeat.
const now = Date.now();
const navigationInvert = this.$store.state.navigationInvert;
if (now - this.lastJogTime < this.jogTime) {
return;
}
// 39 corresponds to the right key
if (39 in this.arrowKeysDown) {
x_rel = x_rel + 1;
this.lastJogTime = now;
this.invokeAction("stage", "jog", {
x: x * this.jogDistance * (navigationInvert.x ? -1 : 1),
y: y * this.jogDistance * (navigationInvert.y ? -1 : 1),
z: z * this.jogDistance,
});
eventBus.emit("globalUpdatePositionEvent");
},
/**
* Stop jogging on key-up
*
* This is also similar to the function in stageControlButtons.vue. It handles
* stopping jogging and resetting the `lastJogTime` so there is no delay when
* starting a new jog after an old jog finished.
*/
jogStop() {
this.invokeAction("stage", "jog", { stop: true });
this.lastJogTime = 0;
setTimeout(() => {
eventBus.emit("globalUpdatePositionEvent");
}, 100);
},
/**
* Track which keys are still down on keypress (or key repeat).
*/
updateJogFromKeys() {
let x = 0,
y = 0,
z = 0;
if (this.keysDown.has("left")) x -= 1;
if (this.keysDown.has("right")) x += 1;
if (this.keysDown.has("up")) y += 1;
if (this.keysDown.has("down")) y -= 1;
if (this.keysDown.has("pageup")) z += 1;
if (this.keysDown.has("pagedown")) z -= 1;
if (x || y || z) {
this.jog(x, y, z);
} else {
this.jogStop();
}
// 38 corresponds to the up key
if (38 in this.arrowKeysDown) {
y_rel = y_rel + 1;
}
// 40 corresponds to the down key
if (40 in this.arrowKeysDown) {
y_rel = y_rel - 1;
}
// Make a position request
// Emit a signal to move, acted on by panelControl.vue
eventBus.emit("globalMoveStepEvent", { x: x_rel, y: y_rel, z: 0 });
},
},
};

View file

@ -3,58 +3,21 @@
<p>Use the buttons below to bring the sample into focus.</p>
<p>You may also adjust the z position with <b>page up</b> and <b>page down</b>.</p>
<template #below-stream>
<div class="action-button-container">
<action-button
class="moveZ"
thing="stage"
action="move_relative"
:button-primary="false"
:submit-data="{ x: 0, y: 0, z: -100 }"
:submit-label="' - '"
:can-terminate="false"
/>
<action-button
class="moveZ"
thing="stage"
action="move_relative"
:button-primary="false"
:submit-data="{ x: 0, y: 0, z: 100 }"
:submit-label="'+'"
:can-terminate="false"
/>
</div>
<stageControlButtons :show-dpad="false" />
</template>
</stepTemplateWithStream>
</template>
<script>
import stepTemplateWithStream from "../stepTemplateWithStream.vue";
import ActionButton from "../../../labThingsComponents/actionButton.vue";
import stageControlButtons from "@/components/tabContentComponents/controlComponents/stageControlButtons.vue";
export default {
name: "CameraMainCalibrationStep",
components: {
stepTemplateWithStream,
ActionButton,
stageControlButtons,
},
};
</script>
<style scoped>
.action-button-container {
display: flex;
flex-direction: row; /* Stack vertically */
justify-content: center; /* Left align */
gap: 8px; /* Small space between buttons */
margin-top: 4px; /* Gap from image */
}
.moveZ :deep(.uk-button.uk-width-1-1) {
line-height: 50px;
font-size: 50px !important;
height: 60px;
padding-bottom: 46px;
margin: 0; /* Remove default margin */
width: 120px;
min-width: 80px;
}
</style>
<style scoped></style>

View file

@ -76,6 +76,7 @@ export default {
return {
setPosition: null,
moveLock: false,
jogging: false,
};
},
@ -92,8 +93,7 @@ export default {
eventBus.on("globalUpdatePositionEvent", this.updatePosition);
// A global signal listener to perform a move action in pixels
eventBus.on("globalMoveInImageCoordinatesEvent", this.onMoveImage);
// A global signal listener to perform a move in multiples of a step size
eventBus.on("globalMoveStepEvent", this.onMoveStep);
// Update the current position in text boxes
await this.updatePosition();
},
@ -103,7 +103,6 @@ export default {
eventBus.off("globalMoveEvent", this.move);
eventBus.off("globalUpdatePositionEvent", this.updatePosition);
eventBus.off("globalMoveInImageCoordinatesEvent", this.onMoveImage);
eventBus.off("globalMoveStepEvent", this.onMoveStep);
},
methods: {
@ -115,22 +114,12 @@ export default {
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
if (this.jogging) return; // Discard move requests if a jog is in progress
// NB moveLock is just boolean flag - it's not as safe as a "proper" lock.
this.moveLock = true; // This will also be set by the task submitter, but
// setting it here avoids multiple moves being requested simultaneously.

View file

@ -1,34 +1,75 @@
<template>
<div class="uk-flex uk-flex-center uk-flex-middle uk-margin">
<div class="dpad-grid">
<button id="up-button" class="uk-button uk-button-primary dpad-btn" @click="move(0, 1, 0)">
<div
class="dpad-grid"
:class="{
'dpad-only': showDpad && !showFocusControls,
'focus-only': !showDpad && showFocusControls,
'both-controls': showDpad && showFocusControls,
}"
>
<button
v-if="showDpad"
id="up-button"
class="uk-button uk-button-primary dpad-btn"
@pointerdown="jog($event, 0, 1, 0)"
@pointerup="jogStop()"
@mouseLeave="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> arrow_upward </span>
</button>
<button id="left-button" class="uk-button uk-button-primary dpad-btn" @click="move(-1, 0, 0)">
<button
v-if="showDpad"
id="left-button"
class="uk-button uk-button-primary dpad-btn"
@pointerdown="jog($event, -1, 0, 0)"
@pointerup="jogStop()"
@pointercancel="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> arrow_back </span>
</button>
<button id="right-button" class="uk-button uk-button-primary dpad-btn" @click="move(1, 0, 0)">
<button
v-if="showDpad"
id="right-button"
class="uk-button uk-button-primary dpad-btn"
@pointerdown="jog($event, 1, 0, 0)"
@pointerup="jogStop()"
@pointercancel="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> arrow_forward </span>
</button>
<button id="down-button" class="uk-button uk-button-primary dpad-btn" @click="move(0, -1, 0)">
<button
v-if="showDpad"
id="down-button"
class="uk-button uk-button-primary dpad-btn"
@pointerdown="jog($event, 0, -1, 0)"
@pointerup="jogStop()"
@pointercancel="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> arrow_downward </span>
</button>
<button
v-if="showFocusControls"
id="focus-out-button"
class="uk-button uk-button-primary dpad-btn"
@click="move(0, 0, -1)"
@pointerdown="jog($event, 0, 0, -1)"
@pointerup="jogStop()"
@pointercancel="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> remove </span>
</button>
<button
v-if="showFocusControls"
id="focus-in-button"
class="uk-button uk-button-primary dpad-btn"
@click="move(0, 0, 1)"
@pointerdown="jog($event, 0, 0, 1)"
@pointerup="jogStop()"
@pointercancel="jogStop()"
>
<span class="material-symbols-outlined sync-icon"> add </span>
</button>
@ -37,13 +78,68 @@
</template>
<script>
import { eventBus } from "../../../eventBus.js";
import { eventBus } from "@/eventBus.js";
export default {
name: "StageControlButtons",
props: {
showDpad: {
type: Boolean,
default: true,
},
showFocusControls: {
type: Boolean,
default: true,
},
},
data: () => ({
jogIntervalId: null,
jogDistance: 600,
jogTime: 300,
}),
methods: {
move(x, y, z) {
eventBus.emit("globalMoveStepEvent", { x, y, z, absolute: false });
/**
* Jog d-pad and focus buttons.
*
* This is a similar to the function in App.vue, however it uses an Interval rather
* than the one in App.vue that uses key repeats.
*/
jog(keyevent, x, y, z) {
// Only respond to primary button (left mouse / primary touch)
if (keyevent.button !== 0) return;
if (this.jogIntervalId) {
clearInterval(this.jogIntervalId);
}
// Designate this element to get the pointers next pointerup event wherever that
// pointer is.
keyevent.target.setPointerCapture(keyevent.pointerId);
const navigationInvert = this.$store.state.navigationInvert;
let invokeJog = () =>
this.invokeAction("stage", "jog", {
x: x * this.jogDistance * (navigationInvert.x ? -1 : 1),
y: y * this.jogDistance * (navigationInvert.y ? -1 : 1),
z: z * this.jogDistance,
});
invokeJog();
this.jogIntervalId = setInterval(invokeJog, this.jogTime);
},
/**
* Stop jogging from d-pad and focus buttons.
*
* This is a similar to the function in App.vue, but it is designed to clear the
* interval used with the d-pad and focus buttons.
*/
jogStop() {
if (this.jogIntervalId) {
clearInterval(this.jogIntervalId);
}
this.invokeAction("stage", "jog", { stop: true });
setTimeout(() => {
eventBus.emit("globalUpdatePositionEvent");
}, 100);
},
},
};
@ -53,12 +149,23 @@ export default {
.dpad-grid {
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: 40px 40px 40px 20px 40px;
gap: 1px;
justify-content: center;
align-items: center;
}
.both-controls {
grid-template-rows: 40px 40px 40px 20px 40px;
}
.dpad-only {
grid-template-rows: 40px 40px 40px;
}
.focus-only {
grid-template-rows: 40px;
}
/* Place buttons within grid */
.dpad-grid #up-button {
grid-column: 2;
@ -76,15 +183,25 @@ export default {
grid-column: 2;
grid-row: 3;
}
.dpad-grid #focus-out-button {
.both-controls #focus-out-button {
grid-column: 1;
grid-row: 5;
}
.dpad-grid #focus-in-button {
.both-controls #focus-in-button {
grid-column: 3;
grid-row: 5;
}
.focus-only #focus-out-button {
grid-column: 1;
grid-row: 1;
}
.focus-only #focus-in-button {
grid-column: 3;
grid-row: 1;
}
.dpad-btn {
width: 40px;
height: 40px;