Split slide scan tab into action tab and seperate controls.

This commit is contained in:
Julian Stirling 2026-03-10 19:55:12 +00:00
parent b4d6436bf3
commit bad736cc46
3 changed files with 373 additions and 196 deletions

View file

@ -0,0 +1,173 @@
<template>
<div ref="actionTab" uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small">
<div v-if="!taskStarted" class="uk-padding-small">
<slot name="controls"></slot>
</div>
<div v-if="taskStarted">
<h2 v-if="taskInfoTitle" style="text-align: center">{{ taskInfoTitle }}</h2>
<mini-stream-display v-if="taskInfoStream" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<action-button
v-if="taskRunning"
:thing="thing"
:action="action"
:force-id="taskId"
:force-url="taskUrl"
submit-label=""
:can-terminate="true"
@completed="$emit('completed')"
@update:task-status="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
<button
v-if="!taskRunning"
type="button"
class="uk-button uk-width-1-1 uk-position-relative"
@click="closeTask"
>
Close
</button>
<slot name="task-info"></slot>
</div>
</div>
<div class="main-view uk-width-expand uk-height-1-1">
<slot></slot>
</div>
</div>
</template>
<script>
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
import ActionButton from "../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "ActionTab",
components: {
actionLogDisplay,
MiniStreamDisplay,
ActionButton,
},
props: {
action: {
type: String,
required: true,
},
thing: {
type: String,
required: true,
},
taskId: {
type: [String, null],
required: true,
},
taskUrl: {
type: [String, null],
required: true,
},
taskInfoTitle: {
type: String,
required: false,
default: null,
},
taskInfoStream: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["closeTask", "completed", "actionStartedExternally"],
data() {
return {
taskStatus: "pending",
progress: null,
log: [],
pollTimer: null,
};
},
computed: {
taskStarted() {
return this.taskId && this.taskUrl;
},
taskRunning() {
return (this.taskStatus == "running") | (this.taskStatus == "pending");
},
},
mounted() {
useIntersectionObserver(
this.$refs.actionTab,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0, // Adjust as needed
},
);
},
methods: {
/**
* Reset the data needed for actions to be polled.
*/
resetData() {
this.taskStatus = "pending";
this.log = [];
},
closeTask() {
// Reset task status to pending so that it is re-read next time an action starts.
this.resetData();
this.$emit("closeTask");
},
visibilityChanged(isVisible) {
this.isVisible = isVisible;
if (this.isVisible) {
this.startTimer();
} else {
this.stopTimer();
}
},
startTimer() {
if (this.pollTimer) return;
this.pollTimer = setInterval(() => {
this.checkIfStartedExternally();
}, 1000);
},
stopTimer() {
if (!this.pollTimer) return;
clearInterval(this.pollTimer);
this.pollTimer = null;
},
async checkIfStartedExternally() {
if (!this.taskStarted | !this.taskRunning) {
const ongoingTask = await this.getOngingAction(this.thing, this.action);
if (ongoingTask) {
this.resetData();
const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href;
this.$emit("actionStartedExternally", ongoingTask.id, taskUrl);
}
}
},
},
};
</script>
<style scoped>
.control-component {
width: 33%;
}
#log-display {
height: 20em;
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<!-- Workflow Selection Dropdown -->
<div ref="slideScanControls" class="uk-margin">
<label class="uk-form-label">Workflow</label>
<select
class="uk-select uk-form-small"
:value="workflowName"
@change="setWorkflow($event.target.value)"
>
<option v-for="(label, name) in workflowOptions" :key="name" :value="name">
{{ label }}
</option>
</select>
</div>
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" />
<ul uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">Stitching Settings</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_automatically"
label="Automatically Stitch Images Together"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_tiff"
label="When Stitching, Produce a Pyramidal TIFF"
/>
</div>
</div>
</li>
</ul>
</template>
<script>
import propertyControl from "@/components/labThingsComponents/propertyControl.vue";
import ServerSpecifiedInterface from "@/components/labThingsComponents/serverSpecifiedInterface.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "SlideScanControls",
components: {
propertyControl,
ServerSpecifiedInterface,
},
data() {
return {
workflowName: undefined,
workflowSettings: [],
workflowOptions: [],
};
},
async created() {
this.readSettings();
this.workflowOptions = await this.readThingProperty(
"smart_scan",
"workflow_display_names",
true,
);
},
mounted() {
// sets visibilityChanged to true or false, which can then update settings if needed
useIntersectionObserver(
this.$refs.slideScanControls,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.readSettings();
}
},
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", true);
this.workflowSettings =
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
}
},
async setWorkflow(name) {
try {
this.workflowName = name;
await this.writeThingProperty("smart_scan", "workflow_name", name);
// refresh UI
await this.readSettings();
} catch (err) {
this.modalError(err);
// revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name", true);
}
},
},
};
</script>
<style scoped></style>

View file

@ -1,206 +1,98 @@
<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" ref="slideScanView" class="uk-padding-small">
<!-- Workflow Selection Dropdown -->
<div class="uk-margin">
<label class="uk-form-label">Workflow</label>
<select
class="uk-select uk-form-small"
:value="workflowName"
@change="setWorkflow($event.target.value)"
>
<option v-for="(label, name) in workflowOptions" :key="name" :value="name">
{{ label }}
</option>
</select>
</div>
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" />
<ul uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">Stitching Settings</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_automatically"
label="Automatically Stitch Images Together"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_tiff"
label="When Stitching, Produce a Pyramidal TIFF"
/>
</div>
</div>
</li>
</ul>
<label class="uk-form-label" for="form-stacked-text">Sample ID</label>
<div class="uk-form-controls">
<input v-model="scan_name" class="uk-input uk-form-small" type="text" name="Scan Name" />
</div>
<div class="uk-margin">
<action-button
ref="smartScanButton"
thing="smart_scan"
action="sample_scan"
:submit-data="{ scan_name: scan_name }"
submit-label="Start Smart Scan"
:can-terminate="true"
@task-started="startScanning"
@update:task-status="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
</div>
<actionTab
thing="smart_scan"
action="sample_scan"
:task-id="taskId"
:task-url="taskUrl"
:task-info-title="infoPaneTitle"
:task-info-stream="displayImageOnRight"
@completed="onScanCompleted"
@close-task="closeTask"
@action-started-externally="startScanning"
>
<!-- MainView -->
<img
v-if="displayImageOnRight"
id="last-stitched-image"
class="image-fit"
:src="lastStitchedImage"
/>
<streamDisplay v-else />
<template #controls>
<slideScanControls />
<label class="uk-form-label" for="form-stacked-text">Sample ID</label>
<div class="uk-form-controls">
<input v-model="scan_name" class="uk-input uk-form-small" type="text" name="Scan Name" />
</div>
<div v-show="scanning">
<h2 v-if="displayImageOnRight" style="text-align: center">Live stitching preview</h2>
<mini-stream-display v-if="displayImageOnRight" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<action-progress-bar :progress="progress" :task-status="taskStatus" />
<button
v-if="cancellable"
type="button"
class="uk-button uk-button-danger uk-width-1-1"
@click="$refs.smartScanButton.terminateTask()"
>
Cancel
</button>
<div v-if="!cancellable" class="uk-margin uk-grid-small uk-child-width-expand" uk-grid>
<button
type="button"
class="uk-button"
@click="
scanning = false;
lastStitchedImage = null;
"
>
Close
</button>
<action-button
thing="smart_scan"
action="download_zip"
submit-label="Download ZIP"
:can-terminate="false"
:submit-data="{ scan_name: lastScanName }"
:button-primary="true"
@response="downloadZipFile"
@error="modalError"
/>
</div>
<div class="uk-margin">
<action-button
ref="smartScanButton"
thing="smart_scan"
action="sample_scan"
:submit-data="{ scan_name: scan_name }"
submit-label="Start Smart Scan"
@task-started="startScanning"
/>
</div>
<h3 v-if="scanning">Scan ID: {{ lastScanName }}</h3>
</div>
<div class="view-image uk-width-expand uk-height-1-1">
<img
v-if="displayImageOnRight"
id="last-stitched-image"
class="image-fit"
:src="lastStitchedImage"
</template>
<template #task-info>
<action-button
v-if="scanComplete"
thing="smart_scan"
action="download_zip"
submit-label="Download ZIP"
:can-terminate="false"
:submit-data="{ scan_name: lastScanName }"
:button-primary="true"
@response="downloadZipFile"
@error="modalError"
/>
<streamDisplay v-else />
</div>
</div>
<h3 v-if="scanning">Scan ID: {{ lastScanName }}</h3>
</template>
</actionTab>
</template>
<script>
import actionTab from "./actionTab.vue";
import slideScanControls from "./slideScanComponents/slideScanControls.vue";
import streamDisplay from "./streamContent.vue";
import propertyControl from "../labThingsComponents/propertyControl.vue";
import ServerSpecifiedInterface from "../labThingsComponents/serverSpecifiedInterface.vue";
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",
components: {
actionTab,
slideScanControls,
streamDisplay,
propertyControl,
actionLogDisplay,
actionProgressBar,
MiniStreamDisplay,
ActionButton,
ServerSpecifiedInterface,
},
data() {
return {
lastScanName: null,
scanning: false,
taskStatus: "pending",
correlateStatus: "",
stitchFromStageStatus: "",
progress: null,
log: [],
taskId: null,
taskUrl: null,
lastStitchedImage: null,
scanComplete: false,
scan_name: "",
workflowName: undefined,
workflowSettings: [],
workflowOptions: [],
};
},
computed: {
cancellable() {
return (this.taskStatus == "running") | (this.taskStatus == "pending");
scanning() {
return this.taskId && this.taskUrl;
},
displayImageOnRight() {
return this.scanning & (this.lastStitchedImage !== null);
return this.scanning && this.lastStitchedImage !== null;
},
infoPaneTitle() {
if (!this.displayImageOnRight) return null;
return "Live stitching preview";
},
},
async created() {
this.readSettings();
this.workflowOptions = await this.readThingProperty(
"smart_scan",
"workflow_display_names",
true,
);
},
mounted() {
useIntersectionObserver(
this.$refs.slideScanView,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.readSettings();
}
},
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", true);
this.workflowSettings =
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
}
},
onScanError: function (error) {
this.scanRunning = false;
this.modalError(error);
},
/**
* Transition the UI into "scanning" mode and begin polling for scan updates.
*
@ -220,13 +112,25 @@ export default {
* - clears any previous preview image
* - starts the polling loop that fetches scan progress and preview images
*/
startScanning() {
startScanning(id, url) {
this.lastStitchedImage = null;
this.scanning = true;
this.lastScanName = null;
this.taskId = id;
this.taskUrl = url;
this.scanComplete = false;
setTimeout(this.pollScan, 1000);
},
onScanCompleted() {
this.scanComplete = true;
},
closeTask() {
this.taskId = null;
this.taskUrl = null;
this.lastStitchedImage = null;
this.scanComplete = false;
},
async pollScan() {
if (this.cancellable) {
if (!this.scanComplete) {
// while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true);
if (mtime !== null) {
@ -237,21 +141,6 @@ export default {
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
}
},
async setWorkflow(name) {
try {
this.workflowName = name;
await this.writeThingProperty("smart_scan", "workflow_name", name);
// refresh UI
await this.readSettings();
} catch (err) {
this.modalError(err);
// revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name", true);
}
},
async downloadZipFile(response) {
const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`;
@ -266,11 +155,4 @@ export default {
};
</script>
<style scoped>
#log-display {
height: 20em;
}
.control-component {
width: 33%;
}
</style>
<style scoped></style>