Rename js directory to webapp

This commit is contained in:
Kaspar Emanuel 2021-08-17 16:14:23 +01:00
parent 375bcf4e73
commit b2eb1e0f05
75 changed files with 5 additions and 5 deletions

View file

@ -0,0 +1,84 @@
<template>
<div>
<form
class="uk-form-stacked"
action=""
method="GET"
@submit="overrideAPIHost"
>
<label class="uk-form-label">Override API origin</label>
<input
v-model="newOrigin"
name="overrideOrigin"
class="uk-input"
type="text"
/>
<label class="uk-form-label">
<input
v-model="reloadWhenOverridingOrigin"
class="uk-input uk-checkbox"
type="checkbox"
/>
Reload web app with new origin
</label>
<button class="uk-button uk-button-default uk-margin-small">
Apply
</button>
</form>
<form class="uk-form-stacked" @submit.prevent="resetTour">
<label class="uk-form-label">Re-run tour on next load</label>
<button class="uk-button uk-button-default uk-margin-small">
Reset
</button>
</form>
</div>
</template>
<script>
// Export main app
export default {
name: "DevTools",
components: {},
data: function() {
return {
newOrigin: this.$store.state.origin,
reloadWhenOverridingOrigin: true
};
},
mounted() {
if (localStorage.overrideOrigin) {
this.newOrigin = localStorage.overrideOrigin;
} else {
this.newOrigin = "http://microscope.local:5000";
}
},
methods: {
overrideAPIHost: function(event) {
// Save the origin override, so that if we reload the web app, you can easily
localStorage.overrideOrigin = this.newOrigin;
// If we have elected not to reload the interface, just update the origin
// in the store. Otherwise, the form's default action will do the job for us.
// TODO: preserve other query parameters when reloading
if (!this.reloadWhenOverridingOrigin) {
this.$store.commit("changeOrigin", this.newOrigin);
event.preventDefault();
}
},
resetTour: function() {
// Make the introduction tour run next time the app loads
this.setLocalStorageObj("completedTour", false);
}
}
};
</script>
<style scoped lang="less">
.error-icon {
font-size: 120px;
}
</style>

View file

@ -0,0 +1,192 @@
<template>
<div class="host-input">
<div v-if="configuration">
<div>
<div class="uk-margin-small-bottom">
<b>API origin:</b>
<br />
{{ $store.state.origin }}
<br />
<b>API URL:</b>
<br />
{{ $store.getters.uriV2 }}
</div>
</div>
<hr />
<div v-if="settings">
<b>Device name:</b> <br />
{{ settings.name }}
</div>
<div>
<b>Server version:</b> <br />
{{ configuration.application.version }}
</div>
<hr />
<div class="uk-margin-small-bottom">
<b>Camera:</b>
<br />
<div v-if="configuration.camera.type != 'MissingCamera'">
{{ configuration.camera.type }}
</div>
<div v-else class="uk-text-danger"><b>No camera connected</b></div>
</div>
<div>
<b>Stage:</b>
<br />
<div v-if="configuration.stage.type != 'MissingStage'">
{{ configuration.stage.type }}
</div>
<div v-else class="uk-text-danger"><b>No stage connected</b></div>
</div>
<hr />
<div class="uk-grid-small uk-child-width-1-2" uk-grid>
<div>
<button
v-show="'shutdown' in systemActionLinks"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="shutdownRequest"
>
Shutdown
</button>
</div>
<div>
<button
v-show="'reboot' in systemActionLinks"
class="uk-button uk-button-danger uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="rebootRequest"
>
Restart
</button>
</div>
</div>
</div>
<div v-else-if="$store.state.waiting">
Loading...
</div>
<div v-else-if="$store.state.error">
<b>Error:</b> {{ $store.state.error }}
</div>
<div v-else>No active connection</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "StatusPane",
components: {},
data: function() {
return {
configuration: null,
settings: null,
systemActionLinks: {}
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configurationUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
rootUri: function() {
return `${this.$store.getters.baseUri}/api/v2`;
}
},
mounted: function() {
// Watch for host 'ready', then update configuration
this.updateConfiguration();
this.updateSettings();
this.updateSystemActions();
},
methods: {
updateConfiguration: function() {
axios
.get(this.configurationUri)
.then(response => {
this.configuration = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateSystemActions: function() {
axios
.get(this.rootUri)
.then(response => {
if ("RebootAPI" in response.data.actions) {
this.$set(
this.systemActionLinks,
"reboot",
`${this.$store.getters.baseUri}/api/v2/actions/system/reboot/`
);
} else {
delete this.systemActionLinks.reboot;
}
if ("ShutdownAPI" in response.data.actions) {
this.$set(
this.systemActionLinks,
"shutdown",
`${this.$store.getters.baseUri}/api/v2/actions/system/shutdown/`
);
} else {
delete this.systemActionLinks.shutdown;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
shutdownRequest: function() {
this.modalConfirm("Shut down microscope?").then(
() => {
if ("shutdown" in this.systemActionLinks) {
this.$store.commit("resetState");
// Post and silence errors
axios.post(this.systemActionLinks.shutdown).catch(() => {});
}
},
() => {}
);
},
rebootRequest: function() {
this.modalConfirm("Restart microscope?").then(
() => {
if ("reboot" in this.systemActionLinks) {
this.$store.commit("resetState");
// Post and silence errors
axios.post(this.systemActionLinks.reboot).catch(() => {});
}
},
() => {}
);
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="less"></style>

View file

@ -0,0 +1,35 @@
<template>
<!-- Grid managing tab content -->
<div class="uk-height-1-1 view-component">
<div class="uk-padding-small">
<statusPane class="uk-width-large" />
</div>
<div class="uk-padding-small">
<h2>Links</h2>
<a
class="uk-link"
target="_blank"
href="https://gitlab.com/openflexure/openflexure-microscope-server/-/issues"
>Report an issue</a
>
</div>
<div class="uk-padding-small">
<h2>Developer tools</h2>
<devTools class="uk-width-large" />
</div>
</div>
</template>
<script>
import devTools from "./aboutComponents/devTools.vue";
import statusPane from "./aboutComponents/statusPane.vue";
export default {
name: "AboutContent",
components: {
devTools,
statusPane
}
};
</script>

View file

@ -0,0 +1,562 @@
<template>
<div id="paneCapture" class="uk-padding-small">
<div>
<label class="uk-form-label" for="form-stacked-text">Filename</label>
<input
v-model="filename"
class="uk-input uk-width-1-1 uk-form-small"
name="inputFilename"
placeholder="Leave blank for default"
/>
</div>
<p uk-tooltip="title: Capture will be removed automatically; delay: 500">
<label
><input v-model="temporary" class="uk-checkbox" type="checkbox" />
Temporary</label
>
</p>
<hr />
<div class="uk-child-width-1-2" uk-grid>
<p>
<label
><input
v-model="fullResolution"
class="uk-checkbox"
type="checkbox"
/>
Full resolution</label
>
</p>
<p>
<label
><input v-model="storeBayer" class="uk-checkbox" type="checkbox" />
Store raw data</label
>
</p>
</div>
<hr />
<p>
<label
><input v-model="resizeCapture" class="uk-checkbox" type="checkbox" />
Resize capture</label
>
</p>
<div class="uk-child-width-1-2" uk-grid>
<div>
<input
v-model="resizeDims[0]"
:class="resizeClass"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputResizeW"
/>
</div>
<div>
<input
v-model="resizeDims[1]"
:class="resizeClass"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputResizeH"
/>
</div>
</div>
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Notes</a>
<div class="uk-accordion-content">
<div class="uk-margin-small">
<textarea
v-model="captureNotes"
class="uk-textarea"
rows="5"
placeholder="Capture notes"
></textarea>
</div>
</div>
</li>
<li>
<a class="uk-accordion-title" href="#">Annotations</a>
<div class="uk-accordion-content">
<keyvalList v-model="annotations" />
</div>
</li>
<li>
<a class="uk-accordion-title" href="#">Tags</a>
<div class="uk-accordion-content">
<tagList v-model="tags" />
</div>
</li>
</ul>
<hr />
<ul uk-accordion="multiple: true">
<!--Show stack and scan if scan plugin is enabled-->
<li v-if="scanUri" :class="{ 'uk-open': scanCapture }">
<a class="uk-accordion-title" href="#">Stack and Scan</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<label
><input
v-model="scanCapture"
class="uk-checkbox"
type="checkbox"
/>
Scan capture</label
>
</div>
<div :class="{ 'uk-disabled': !scanCapture }">
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>y step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z step-size</label
>
<div class="uk-form-controls">
<input
v-model="scanStepSize.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZ"
/>
</div>
</div>
</div>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
min="1"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>y steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
min="1"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z steps</label
>
<div class="uk-form-controls">
<input
v-model="scanSteps.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZ"
min="1"
/>
</div>
</div>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Autofocus</label
>
<select v-model="scanDeltaZ" class="uk-select">
<option>Off</option>
<option>Coarse</option>
<option>Medium</option>
<option>Fine</option>
<option>Fast</option>
</select>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Scan Style</label
>
<select v-model="scanStyle" class="uk-select">
<option>Raster</option>
<option>Snake</option>
<option>Spiral</option>
</select>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Naming style</label
>
<select v-model="namingStyle" class="uk-select">
<option>Coordinates</option>
<option>Number</option>
</select>
</div>
</div>
</div>
</li>
<!--Show stack and scan if scan plugin is enabled-->
<li v-if="scanUri" :class="{ 'uk-open': smartStack && scanCapture }">
<a class="uk-accordion-title" href="#">Smart Stack</a>
<div
class="uk-accordion-content"
:class="{ 'uk-disabled': !scanCapture }"
>
<div class="uk-margin">
<label
><input
v-model="smartStack"
class="uk-checkbox"
type="checkbox"
/>
Enable smart stacking</label
>
</div>
<p>
Smart stacking is an experimental closed-loop Z stack, that checks
the Z stack is centred on the focus. It requires Z stacking to be
enabled above (we recommend 9 images in Z) and you must select
"fast" autofocus.
</p>
<div :class="{ 'uk-disabled': !smartStack }">
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Autofocus range (steps)</label
>
<input
v-model="smartStackAutofocusDz"
class="uk-input uk-form-small"
type="number"
min="1000"
/>
</div>
<div class="uk-margin-small uk-margin-remove-bottom">
<label class="uk-form-label" for="form-stacked-text"
>Alignment range (steps)</label
>
<input
v-model="smartStackAlignDist"
class="uk-input uk-form-small"
type="number"
min="100"
/>
</div>
</div>
</div>
</li>
</ul>
<div v-if="scanCapture" class="uk-margin uk-margin-remove-top">
<taskSubmitter
v-if="smartStack"
:submit-url="smartScanUri"
:submit-data="smartScanPayload"
:submit-label="'Start Smart Scan'"
:button-primary="true"
@response="onScanResponse"
@error="modalError"
>
</taskSubmitter>
<taskSubmitter
v-if="!smartStack"
:submit-url="scanUri"
:submit-data="scanPayload"
:submit-label="'Start Scan'"
:button-primary="true"
@response="onScanResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<button
v-else
class="uk-button uk-button-primary uk-margin uk-margin-remove-top uk-width-1-1"
@click="handleCapture()"
>
Capture
</button>
</div>
</template>
<script>
import axios from "axios";
import tagList from "../../fieldComponents/tagList";
import keyvalList from "../../fieldComponents/keyvalList";
import taskSubmitter from "../../genericComponents/taskSubmitter";
import { syncDataWithLocalStorage } from "../../../syncDataWithLocalStorage";
/**
* The capture settings that should persist in local storage are these ones
*/
function defaultCaptureSettings() {
return {
filename: "",
temporary: false,
fullResolution: false,
storeBayer: false,
resizeCapture: false,
captureNotes: "",
scanDeltaZ: "Fast",
scanStyle: "Raster",
namingStyle: "Coordinates",
scanStepSize: {
x: 800,
y: 640,
z: 50
},
scanSteps: {
x: 3,
y: 3,
z: 5
},
resizeDims: [640, 480],
tags: [],
annotations: {
Client: "openflexure-microscope-jsclient:builtin"
},
smartStack: false,
smartStackThreshold: 0.9,
smartStackPeakWidth: 200,
smartStackAlignDist: 900,
smartStackBacklash: 100,
smartStackFitStyle: "chevy",
smartStackMAndMIndex: 10,
smartStackAutofocusDz: 3000
};
}
// Export main app
export default {
name: "PaneCapture",
components: {
tagList,
keyvalList,
taskSubmitter
},
data: function() {
return {
...defaultCaptureSettings(),
scanCapture: false, // Don't remember the "scan" tickbox in local storage.
scanUri: null,
smartScanUri: null
};
},
computed: {
resizeClass: function() {
return {
"uk-disabled": !this.resizeCapture
};
},
captureActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
basePayload: function() {
var payload = {};
// Filename
if (this.filename) {
payload.filename = this.filename;
}
// Basic boolean params
payload.temporary = this.temporary;
payload.use_video_port = !this.fullResolution;
payload.bayer = this.storeBayer;
// Resizing
if (this.resizeCapture) {
payload.resize = {
width: this.resizeDims[0],
height: this.resizeDims[1]
};
}
// Additional annotations
payload.annotations = this.annotations;
payload.tags = this.tags;
// Attach notes
if (this.captureNotes) {
payload.annotations["Notes"] = this.captureNotes;
}
return payload;
},
scanPayload: function() {
// Convert AF selector to dz
var afDeltas = {
Off: 0,
Coarse: 100,
Medium: 30,
Fine: 10,
Fast: 2000
};
return {
...this.basePayload,
// Scan params
grid: [this.scanSteps.x, this.scanSteps.y, this.scanSteps.z],
stride_size: [
this.scanStepSize.x,
this.scanStepSize.y,
this.scanStepSize.z
],
style: this.scanStyle.toLowerCase(),
namemode: this.namingStyle.toLowerCase(),
autofocus_dz: afDeltas[this.scanDeltaZ],
fast_autofocus: this.scanDeltaZ == "Fast"
};
},
smartScanPayload: function() {
return {
...this.scanPayload,
threshold: this.smartStackThreshold,
width: this.smartStackPeakWidth,
align_dist: this.smartStackAlignDist,
backlash: this.smartStackBacklash,
fit_style: this.smartStackFitStyle,
m_and_m_index: this.smartStackMAndMIndex,
autofocus_dz: this.smartStackAutofocusDz
};
}
},
mounted() {
this.updateScanUri();
// Load settings if they have been saved, and set up watchers to sync with local storage
syncDataWithLocalStorage("captureSettings", this, defaultCaptureSettings());
// A global signal listener to perform a capture action
this.$root.$on("globalCaptureEvent", () => {
this.handleCapture();
});
},
methods: {
handleCapture: function() {
var payload = this.basePayload;
// Do capture
axios
.post(this.captureActionUri, payload)
.then(() => {
// Flash the stream (capture animation)
this.$root.$emit("globalFlashStream");
// Update the global capture list
this.$root.$emit("globalUpdateCaptures");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateScanUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.scan"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.scanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.smart-stack"
);
if (foundExtension) {
// Get plugin action link
this.smartScanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanResponse: function() {
this.modalNotify("Finished scan.");
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
}
}
};
</script>
<style lang="less">
.deletable-label {
cursor: pointer;
}
.deletable-label:hover {
background-color: #f0506e;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneCapture />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneCapture from "./captureComponents/paneCapture";
import streamDisplay from "./streamContent.vue";
export default {
name: "CaptureContent",
components: {
paneCapture,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,76 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<vue-friendly-iframe v-if="frame" :src="frame.href"></vue-friendly-iframe>
<!-- Handle OpenFlexure Forms -->
<div
v-for="form in forms"
v-else-if="forms"
:key="`${form.route}/${form.name}`.replace(/\s+/g, '-').toLowerCase()"
class="uk-height-1-1 uk-width-1-1"
>
<JsonForm
:name="form.name"
:route="form.route"
:is-task="form.isTask"
:submit-label="form.submitLabel"
:schema="form.schema"
:emit-on-response="form.emitOnResponse"
v-on="$listeners"
/>
</div>
</div>
<div class="view-component uk-width-expand">
<galleryContent v-if="viewPanel == 'gallery'" />
<settingsContent v-else-if="viewPanel == 'settings'" />
<streamDisplay v-else />
</div>
</div>
</template>
<script>
import JsonForm from "../pluginComponents/JsonForm";
import streamDisplay from "./streamContent.vue";
import galleryContent from "../tabContentComponents/galleryContent.vue";
import settingsContent from "../tabContentComponents/settingsContent.vue";
export default {
name: "ExtensionContent",
components: {
JsonForm,
streamDisplay,
galleryContent,
settingsContent
},
props: {
forms: {
type: Array,
required: false,
default: () => []
},
frame: {
type: Object,
required: false,
default: null
},
viewPanel: {
type: String,
required: false,
default: "stream"
}
}
};
</script>
<style>
.vue-friendly-iframe {
height: 100%;
}
.vue-friendly-iframe iframe {
height: 100%;
}
</style>

View file

@ -0,0 +1,389 @@
<template>
<div
class="capture-card uk-card uk-card-default uk-padding-remove uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.darkMode }"
>
<div class="uk-card-media-top">
<a class="lightbox-link" :href="imgURL" :data-caption="name">
<img
class="uk-width-1-1"
:data-src="thumbURL"
:alt="id"
width="300"
height="225"
uk-img
/>
</a>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right"
uk-grid
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
{{ name }}
</div>
<div class="uk-margin-remove-top uk-padding-remove uk-width-auto">
<a href="#" class="uk-icon" @click="delCaptureConfirm()">
<i class="material-icons">delete</i>
</a>
</div>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-expand"
>
<time>{{ time }}</time>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-auto"
>
<a :href="metadataModalTarget" uk-toggle>More...</a>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<button
v-if="openInImjoyMenuItems.length != 0"
class="uk-icon"
type="button"
>
<img
style="width:25px;"
src="https://imjoy.io/static/img/imjoy-icon.svg"
/>
</button>
<div uk-dropdown="pos: top-center">
<ul class="uk-nav uk-dropdown-nav">
<li v-for="item in openInImjoyMenuItems" :key="item.name">
<a href="#" @click="item.callback(name, imgURL)"
><i class="material-icons">launch</i>{{ item.title }}</a
>
</li>
</ul>
</div>
&nbsp;
<div v-for="tag in tags" :key="tag" class="uk-display-inline">
<span
v-if="tag === 'temporary'"
class="uk-label uk-label-danger uk-margin-small-right"
uk-tooltip="title: Capture will be removed automatically; delay: 500"
>Temporary</span
>
<span
v-else
class="uk-label uk-margin-small-right deletable-label"
@click="delTagConfirm(tag)"
>
{{ tag }}
</span>
</div>
<a :href="tagModalTarget" uk-toggle>
<span class="uk-label uk-label-success uk-margin-small-right">Add</span>
</a>
</div>
<!-- Metadata modal -->
<div :id="metadataModalID" uk-modal>
<div
class="uk-modal-dialog uk-modal-body"
:class="{
'uk-light uk-background-secondary': $store.state.darkMode
}"
>
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="uk-modal-title">{{ name }}</h2>
<p><b>Path: </b>{{ path }}</p>
<p><b>Time: </b>{{ time }}</p>
<p><b>ID: </b>{{ id }}</p>
<p><b>Format: </b>{{ format }}</p>
<hr />
<div v-for="(value, key) in annotations" :key="key">
<p>
<b>{{ key }}: </b>{{ value }}
</p>
</div>
<hr />
<div class="uk-flex-bottom" uk-grid>
<div class="uk-width-2-3">
<keyvalList v-model="newAnnotations" />
</div>
<div class="uk-width-1-3">
<button
class="uk-button uk-button-primary uk-form-small uk-width-1-1"
@click="handleAnnotationsSubmit()"
>
Add annotation
</button>
</div>
</div>
</div>
</div>
<!-- New tag modal -->
<div :id="tagModalID" uk-modal>
<form
class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical"
:class="{
'uk-light uk-background-secondary': $store.state.darkMode
}"
@submit.prevent="handleTagSubmit"
>
<div class="uk-inline">
<span class="uk-form-icon"><i class="material-icons">label</i></span>
<input
v-model="newTag"
autofocus
class="uk-input uk-form-width-medium uk-form-small"
type="text"
name="tagname"
placeholder="tag"
/>
<button
class="uk-button uk-button-default uk-margin-left uk-form-small uk-modal-close"
type="button"
>
Cancel
</button>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-left uk-form-small"
>
Save
</button>
</div>
</form>
</div>
</div>
</template>
<script>
import UIkit from "uikit";
import axios from "axios";
import { mapState } from "vuex";
import keyvalList from "../../fieldComponents/keyvalList";
// Export main app
export default {
name: "CaptureCard",
components: {
keyvalList
},
props: {
id: {
type: String,
required: true
},
name: {
type: String,
required: true
},
time: {
type: String,
required: true
},
path: {
type: String,
required: true
},
format: {
type: String,
required: true
},
links: {
type: Object,
required: true
},
tags: {
type: Array,
required: false,
default: function() {
return [];
}
},
annotations: {
type: Object,
required: false,
default: function() {
return {};
}
}
},
data: function() {
return {
newTag: "",
newAnnotations: {}
};
},
computed: {
tagModalID: function() {
return this.makeModalName("tag-modal-");
},
tagModalTarget: function() {
return "#" + this.tagModalID;
},
metadataModalID: function() {
return this.makeModalName("metadata-modal-");
},
metadataModalTarget: function() {
return "#" + this.metadataModalID;
},
thumbURL: function() {
return `${this.links.download.href}?thumbnail=true`;
},
imgURL: function() {
return this.links.download.href;
},
tagsURL: function() {
return this.links.tags.href;
},
annotationsURL: function() {
return this.links.annotations.href;
},
captureURL: function() {
return this.links.self.href;
},
...mapState("imjoy", { openInImjoyMenuItems: "openImageMenu" })
},
created: function() {},
methods: {
getMetadata: function() {
// Send metadata request
axios
.get(this.captureURL)
.then(response => {
this.$emit("update:tags", response.data.metadata.image.tags);
this.$emit(
"update:annotations",
response.data.metadata.image.annotations
);
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
handleTagSubmit: function(event) {
if (this.newTag !== "") {
this.newTagRequest(this.newTag);
this.newTag = "";
}
UIkit.modal(event.target.parentNode).hide();
},
handleAnnotationsSubmit: function() {
this.putAnnotationsRequest(this.newAnnotations);
this.newAnnotations = {};
},
delCaptureConfirm: function() {
var context = this;
this.modalConfirm("Permanantly delete capture?").then(function() {
context.delCaptureRequest();
});
},
delCaptureRequest: function() {
// Send tag DELETE request
axios
.delete(this.captureURL)
.then(() => {
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
newTagRequest: function(tagString) {
// Send tag PUT request
axios
.put(this.tagsURL, [tagString])
.then(() => {
// Update tag array
this.getTagRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
putAnnotationsRequest: function(annotationsObject) {
// Send metadata PUT request
axios
.put(this.annotationsURL, annotationsObject)
.then(() => {
// Update metadata object
this.getAnnotationsRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
delTagConfirm: function(tagString) {
var context = this;
this.modalConfirm(`Remove tag '${tagString}'?`).then(function() {
context.delTagRequest(tagString);
});
},
delTagRequest: function(tagString) {
// Send tag DELETE request
axios
.delete(this.tagsURL, { data: [tagString] })
.then(() => {
// Update tag array
this.getTagRequest();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getTagRequest: function() {
// Send tag request
axios
.get(this.tagsURL)
.then(response => {
// Pass the update tag array to the parent
this.$emit("update:tags", response.data);
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getAnnotationsRequest: function() {
// Send tag request
axios
.get(this.annotationsURL)
.then(response => {
// Pass the update annotation array to the parent
this.$emit("update:annotations", response.data);
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
makeModalName: function(prefix) {
return prefix + this.id;
}
}
};
</script>

View file

@ -0,0 +1,153 @@
<template>
<div
class="capture-card uk-card uk-card-primary uk-padding-remove uk-width-medium"
>
<div class="uk-card-media-top">
<a href="#">
<img
class="uk-width-1-1"
:data-src="thumbnail"
:alt="id"
width="300"
height="225"
uk-img
@click="onClick"
/>
</a>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right"
uk-grid
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
<b>{{ type }}: </b>
{{ name }}
</div>
<div class="uk-margin-remove-top uk-padding-remove uk-width-auto">
<a href="#" class="uk-icon" @click="delAllConfirm()">
<i class="material-icons">delete</i>
</a>
</div>
</div>
<div
class="uk-text-meta uk-margin-remove-top uk-padding-remove uk-width-expand"
>
<time>{{ time }}</time>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<button
v-if="openInImjoyMenuItems.length != 0"
class="uk-icon"
type="button"
>
<img
style="width:25px;"
src="https://imjoy.io/static/img/imjoy-icon.svg"
/>
</button>
<div uk-dropdown="pos: top-center">
<ul class="uk-nav uk-dropdown-nav">
<li v-for="item in openInImjoyMenuItems" :key="item.name">
<a
href="#"
style="color:black"
@click="item.callback(name, allURLs)"
><i class="material-icons">launch</i>{{ item.title }}</a
>
</li>
</ul>
</div>
&nbsp;
<span
v-for="tag in tags"
:key="tag"
class="uk-label uk-margin-small-right deletable-label"
>
{{ tag }}
</span>
</div>
</div>
</template>
<script>
import axios from "axios";
import { mapState } from "vuex";
// Export main app
export default {
name: "ScanCard",
props: {
id: {
type: String,
required: true
},
name: {
type: String,
required: true
},
time: {
type: String,
required: true
},
type: {
type: String,
required: false,
default: "Dataset"
},
thumbnail: {
type: String,
required: true
},
captures: {
type: Array,
required: true
},
tags: {
type: Array,
required: false,
default: function() {
return [];
}
}
},
computed: {
allURLs: function() {
var urls = [];
for (var capture of this.captures) {
urls.push(capture.links.self.href);
}
return urls;
},
...mapState("imjoy", { openInImjoyMenuItems: "openScanMenu" })
},
methods: {
onClick: function() {
this.$emit("selectFolder", this.id);
},
delAllConfirm: function() {
var context = this;
this.modalConfirm(
"Permanantly delete all captures in this dataset?"
).then(function() {
context.deleteAll();
});
},
deleteAll: function() {
axios.all(this.allURLs.map(l => axios.delete(l))).then(() => {
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
});
}
}
};
</script>

View file

@ -0,0 +1,169 @@
<template>
<div v-if="zipBuilderUri" class="uk-width-small">
<div v-show="downloadReady">
<button
:disabled="isDownloading"
type="button"
class="uk-button uk-button-default uk-width-1-1"
@click="downloadWithAxios()"
>
{{ isDownloading ? `${downloadProgress}%` : "Download" }}
</button>
</div>
<div v-show="!downloadReady">
<taskSubmitter
v-if="zipBuilderUri"
:can-terminate="false"
:submit-url="zipBuilderUri"
:submit-label="'Create ZIP'"
:submit-data="captureIds"
@response="onResponse"
@error="modalError"
>
</taskSubmitter>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "ZipDownloader",
components: {
taskSubmitter
},
props: {
captureIds: {
type: Array,
required: true
}
},
data: function() {
return {
downloadProgress: 0,
isDownloading: false,
downloadReady: false,
downloadUrl: null,
zipBuilderUri: null,
zipGetterUri: null,
lastSessionId: null
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted: function() {
this.updateZipperUri();
},
created: function() {
// Watch for host 'ready', then update status
this.unwatchStoreFunction = this.$store.watch(
(state, getters) => {
return getters.ready;
},
ready => {
if (ready) {
// If the connection is now ready, update zipper URL
this.updateZipperUri();
}
}
);
},
methods: {
updateZipperUri: function() {
if (this.$store.state.available) {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.zipbuilder"
);
// if ZipBuilderPlugin is enabled
if (foundExtension) {
// Get plugin action links
this.zipBuilderUri = foundExtension.links.build.href;
this.zipGetterUri = foundExtension.links.get.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} else {
this.zipBuilderUri = null;
this.zipGetterUri = null;
}
},
resetZipper: function() {
this.downloadReady = false;
},
forceFileDownload(response) {
// Start file download by creating and clicking an invisible link
// One day I hope for a better solution to exist for this...
// A man can dream.....
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", `${this.lastSessionId}.zip`); //or any other extension
document.body.appendChild(link);
link.click();
},
downloadWithAxios() {
// Configure progress indicator and response type
let config = {
responseType: "blob",
onDownloadProgress: progressEvent => {
this.downloadProgress = Math.floor(
(progressEvent.loaded * 100) / progressEvent.total
);
}
};
// Set downloading flag
this.isDownloading = true;
// Start download
axios
.get(this.downloadUrl, config)
.then(response => {
this.forceFileDownload(response);
this.deleteLastZip();
this.resetZipper();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.finally(() => {
this.isDownloading = false;
});
},
deleteLastZip() {
axios.delete(this.downloadUrl).catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onResponse: function(response) {
this.lastSessionId = response.output.id;
this.downloadUrl = `${this.zipGetterUri}/${this.lastSessionId}`;
this.downloadReady = true;
}
}
};
</script>

View file

@ -0,0 +1,455 @@
<template>
<div 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"
>
<!-- Left side controls -->
<div
class="uk-navbar-left uk-padding-remove-top uk-padding-remove-bottom"
>
<ul class="uk-navbar-nav">
<li :class="[sortDescending ? 'uk-active' : '']">
<a class="uk-icon" href="#" @click="sortDescending = true">
<i class="material-icons">keyboard_arrow_down</i>
</a>
</li>
<li :class="[!sortDescending ? 'uk-active' : '']">
<a class="uk-icon" href="#" @click="sortDescending = false">
<i class="material-icons">keyboard_arrow_up</i>
</a>
</li>
<li>
<a href="#">Filter</a>
<div
:class="{
'uk-light uk-background-secondary': $store.state.darkMode
}"
class="uk-navbar-dropdown"
>
<ul class="uk-nav uk-navbar-dropdown-nav">
<form class="uk-form-stacked">
<div
v-for="tag in allTags"
:key="tag"
class="uk-margin-small"
>
<label>
<input
:id="tag"
v-model="checkedTags"
:value="tag"
checked
class="uk-checkbox"
type="checkbox"
/>
{{ tag }}
</label>
</div>
</form>
</ul>
</div>
</li>
</ul>
</div>
<!-- Right side buttons -->
<div class="uk-navbar-right">
<div class="uk-grid">
<div>
<button
class="uk-button uk-button-default uk-width-1-1"
type="button"
@click="updateCaptures()"
>
Refresh Captures
</button>
</div>
<div>
<ZipDownloader :capture-ids="Object.keys(filteredCaptures)" />
</div>
</div>
</div>
</nav>
<!-- Gallery -->
<div
v-if="$store.getters.ready"
class="uk-padding-remove-top"
uk-lightbox="toggle: .lightbox-link"
>
<!-- Folder heading -->
<div
v-if="galleryFolder"
class="gallery-folder-heading uk-flex uk-flex-middle"
>
<a class="uk-icon uk-margin-remove" href="#" @click="galleryBack()">
<i class="material-icons">arrow_back</i>
</a>
<div class="uk-margin-left">
<h3 class="uk-margin-remove uk-margin-left">
<b>SCAN</b>
{{ allScans[galleryFolder].name }}
</h3>
</div>
</div>
<!-- Gallery capture cards -->
<div class="gallery-grid">
<div v-for="item in pagedItems" :key="item.id">
<scanCard
v-if="'isScan' in item"
:id="item.id"
:name="item.name"
:time="item.time"
:tags="item.tags"
:type="item.type"
:captures="item.captures"
:thumbnail="item.thumbnail"
@selectFolder="selectFolder"
/>
<captureCard
v-else
:id="item.id"
:links="item.links"
:name="item.name"
:format="item.format"
:path="item.path"
:time="item.time"
:tags="item.tags"
:annotations="item.annotations"
@update:tags="item.tags = $event"
@update:annotations="item.annotations = $event"
/>
</div>
</div>
<Paginate
v-model="page"
:page-count="numberOfPages"
:page-range="3"
:margin-pages="1"
:container-class="'uk-pagination uk-flex-center'"
:prev-text="'Prev'"
:next-text="'Next'"
:page-class="'page-item'"
:active-class="'uk-active'"
:disabled-class="'uk-disabled'"
:click-handler="scrollToTop()"
>
</Paginate>
</div>
</div>
</template>
<script>
import axios from "axios";
import Paginate from "vuejs-paginate";
import captureCard from "./galleryComponents/captureCard.vue";
import scanCard from "./galleryComponents/scanCard.vue";
import ZipDownloader from "./galleryComponents/zipDownloader";
// Export main app
export default {
name: "GalleryDisplay",
components: {
captureCard,
scanCard,
ZipDownloader,
Paginate
},
data: function() {
return {
captures: [],
checkedTags: [],
sortDescending: true,
galleryFolder: "",
scanTag: "scan",
unwatchStoreFunction: null,
maxitems: 10,
page: 1
};
},
computed: {
capturesUri: function() {
return `${this.$store.getters.baseUri}/api/v2/captures`;
},
allTags: function() {
// Return an array of unique tags across all captures
var tags = [];
for (var capture of this.captures) {
for (var tag of capture.tags) {
if (!tags.includes(tag)) {
tags.push(tag);
}
}
}
return tags.sort();
},
noScanCaptures: function() {
// List of captures that are not part of a scan
var captures = [];
for (var capture of this.captures) {
// Add to capture list if matched
if (!this.isDatasetPopulated(capture.dataset)) {
captures.push(capture);
}
}
return captures;
},
allScans: function() {
// List of scans as capture-like objects
var scans = {};
for (var capture of this.captures) {
var dataset = capture.dataset;
if (this.isDatasetPopulated(dataset)) {
var id = dataset["id"];
// If this scan ID hasn't been seen before
if (!(id in scans)) {
scans[id] = {};
scans[id].id = id;
scans[id].name = dataset.name;
scans[id].type = dataset.type;
// Use time of first found capture in dataset
scans[id].time = capture.time;
scans[id].isScan = true;
scans[id].captures = [];
scans[id].tags = [];
}
// Add the capture object to the scan
scans[id].captures.push(capture);
// Append missing tags
for (var tag of capture.tags) {
if (!scans[id].tags.includes(tag)) {
scans[id].tags.push(tag);
}
}
// Create a preview thumbnail
if (!("thumbnail" in scans[id])) {
scans[
id
].thumbnail = `${capture.links.download.href}?thumbnail=true`;
}
}
}
return scans;
},
scanList: function() {
// List of scans, obtained from this.allScans values
return Object.values(this.allScans);
},
itemList: function() {
// Get list of current items to show
// If galleryFolder (ie inside a scan folder), show scan captures
// Otherwise, show root captures and scan cards
if (this.galleryFolder) {
return this.allScans[this.galleryFolder].captures;
} else {
return this.noScanCaptures.concat(this.scanList);
}
},
filteredItems: function() {
// Filter itemList by checkedTags
return this.filterCaptures(this.itemList, this.checkedTags);
},
filteredCaptures: function() {
var captures = {};
for (var item of this.filteredItems) {
// If it's a dataset
if ("captures" in item) {
for (var capture of item.captures) {
// Get the ID of each capture in the set
captures[capture.id] = capture;
}
} else {
// If it's a single capture, get the ID of the capture
captures[item.id] = item;
}
}
return captures;
},
sortedItems: function() {
// Sort filteredItems using sortCaptures function
return this.sortCaptures(this.filteredItems);
},
pagedItems: function() {
let startIndex = (this.page - 1) * this.maxitems;
return this.sortedItems.slice(startIndex, startIndex + this.maxitems);
},
numberOfPages: function() {
return Math.floor(this.sortedItems.length / this.maxitems);
}
},
mounted() {
// Update on mount (does nothing if not connected)
this.updateCaptures();
// A global signal listener to perform a gallery refresh
this.$root.$on("globalUpdateCaptures", () => {
this.updateCaptures();
});
},
created: function() {
// Watch for host 'ready', then update status
this.unwatchStoreFunction = this.$store.watch(
(state, getters) => {
return getters.ready;
},
ready => {
if (ready) {
// If the connection is now ready, update capture list
this.updateCaptures();
} else {
// If the connection is now disconnected, empty capture list
this.captures = {};
}
}
);
},
beforeDestroy() {
// Remove global signal listener to perform a gallery refresh
this.$root.$off("globalUpdateCaptures");
// Then we call that function here to unwatch
if (this.unwatchStoreFunction) {
this.unwatchStoreFunction();
this.unwatchStoreFunction = null;
}
},
methods: {
scrollToTop() {
const el = document.querySelector("#container-left");
if (el) {
el.scrollTop = 0;
}
},
updateCaptures: function() {
if (this.$store.state.available) {
axios
.get(this.capturesUri)
.then(response => {
this.captures = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
isDatasetPopulated: function(dataset) {
if (
!dataset || // If no dataset key
(dataset.constructor === Object && // Or dataset is an object...
Object.keys(dataset).length === 0) // ...but it's empty
) {
return false;
} else {
return true;
}
},
filterCaptures: function(list, filterTags) {
// Filter a list of captures by an array of tags
var result = [];
for (var capture of list) {
// Assume exclusion
var includeCapture = false;
// Filter by selected tags
var tags = capture.tags;
let checker = (arr, target) => target.every(v => arr.includes(v));
// True if all tags match
includeCapture = checker(tags, filterTags);
// Add to capture list if matched
if (includeCapture == true) {
result.push(capture);
}
}
return result;
},
sortCaptures: function(list) {
// Sort a list of captures by metadata time
function compare(a, b) {
if (a.time < b.time) return -1;
if (a.time > b.time) return 1;
return 0;
}
if (this.sortDescending == true) {
return list.sort(compare).reverse();
} else {
return list.sort(compare);
}
},
galleryBack: function() {
this.galleryFolder = "";
this.page = 1;
},
selectFolder: function(folderID) {
this.galleryFolder = folderID;
}
}
};
</script>
<style lang="less" scoped>
.gallery-navbar {
border-width: 0 0 1px 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.gallery-navbar,
.gallery-folder-heading {
margin-bottom: 30px;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 320px);
justify-content: center;
overflow-y: auto;
}
.gallery-grid > div {
display: inline-block;
margin-bottom: 20px;
}
/deep/ .capture-card {
width: 300px;
height: 100%; // Used to have all cards in a row match their heights
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,58 @@
<template>
<div
class="uk-card uk-card-hover uk-card-small uk-card-default uk-margin"
:class="{ 'uk-card-secondary': $store.state.darkMode }"
>
<div class="uk-card-body">
<div class="uk-grid-small uk-flex-middle" uk-grid>
<div class="uk-width-auto">
<img
v-if="iconURL"
style="height:50px"
:data-src="iconURL"
:alt="name + ' icon'"
uk-img
/>
<i v-if="!iconURL" class="material-icons" style="font-size:50px">
{{ iconName }}
</i>
</div>
<div class="uk-width-expand">
<h3 class="uk-card-title uk-margin-remove-bottom">{{ name }}</h3>
<p class="uk-text-meta uk-margin-remove-top">
<slot name="subtitle"></slot>
</p>
</div>
<div class="uk-width-auto">
<button class="uk-button" @click="$emit('click')">
<slot name="buttonText">Launch</slot>
</button>
</div>
</div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: "ImjoyPluginCard",
props: {
name: {
type: String,
required: true
},
iconURL: {
type: String,
required: false,
default: () => ""
},
iconName: {
type: String,
required: false,
default: () => "extension"
}
}
};
</script>

View file

@ -0,0 +1,644 @@
<template>
<!-- Grid managing tab content -->
<div class="uk-height-1-1 view-component">
<progress
ref="imjoyProgressbar"
style="height: 4px; margin-bottom:0!important"
class="uk-progress"
max="100"
></progress>
<div
class="uk-flex uk-flex-column uk-margin uk-margin-left uk-margin-right"
>
<imjoy-plugin-card
name="Load From URL"
icon-u-r-l="https://imjoy.io/static/img/imjoy-icon.svg"
@click="loadPluginDialog()"
>
<template v-slot:buttonText>Open</template>
</imjoy-plugin-card>
<imjoy-plugin-card
name="ImageJ.JS"
icon-u-r-l="https://ij.imjoy.io/assets/icons/chrome/chrome-installprocess-128-128.png"
@click="openImageJ()"
/>
<imjoy-plugin-card
name="Kaibu"
icon-u-r-l="https://kaibu.org/static/img/kaibu-icon.svg"
@click="startKaibu()"
/>
<imjoy-plugin-card
v-for="(p, name) in loadedPlugins"
:key="p.id"
:name="name"
icon-name="extension"
@click="runPlugin(p)"
/>
</div>
<div v-show="loading" class="progress uk-margin-small">
<div class="indeterminate"></div>
</div>
</div>
</template>
<script>
import * as imjoyCore from "imjoy-core";
import axios from "axios";
import UIkit from "uikit";
import { mapState } from "vuex";
import ImjoyPluginCard from "./imjoyComponents/imjoyPluginCard.vue";
// TODO: move this to a module or something...
async function pollAction(response) {
// Poll an action until its status is completed
// TODO: raise an error if it fails or is cancelled
// For ease, this accepts and returns a response object from
// axios.get
// FIXME: this could be an infinite loop if the task is cancelled/errored
let r = response;
while ((r.data.status == "running") | (r.data.status == "pending")) {
await new Promise(resolve => setTimeout(resolve, 100));
r = await axios.get(r.data.href);
}
return r;
}
/**
* Create a new Image element and wait for it to load.
*
* This returns a Promise, so you can "await" the image
* element rather than needing to add a callback.
*/
function loadImageFromUrl(src) {
// Create an Image, and return it once it has loaded
// see https://stackoverflow.com/a/66180709
// no need to declare async, as it returns a Promise
return new Promise((resolve, reject) => {
const img = new Image();
// We must set crossOrigin="anonymous" in order
// to avoid security errors when we get the pixel
// data
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/**
* Retrieve an image from a URL, and return the RGB data
* in a format that can be cast to an ndarray
*
* This follows a slightly convoluted recipe, where we
* "draw" the image, and then extract the pixels from the
* 2D canvas.
*/
async function imageUrlToNdarray(url) {
// see https://stackoverflow.com/questions/934012/get-image-data-url-in-javascript
const img = await loadImageFromUrl(url);
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const rgbaData = imageData.data; // This gets the RGBA values as an ArrayBuffer
// convert RGBA to RGB
const rgbData = new Uint8Array(new ArrayBuffer(img.height * img.width * 3));
for (let i = 0; i < img.height; i++) {
for (let j = 0; j < img.width; j++) {
const pos = i * img.width + j;
rgbData[pos * 3] = rgbaData[pos * 4];
rgbData[pos * 3 + 1] = rgbaData[pos * 4 + 1];
rgbData[pos * 3 + 2] = rgbaData[pos * 4 + 2];
}
}
return {
_rtype: "ndarray",
_rdtype: "uint8",
_rshape: [img.height, img.width, 3],
_rvalue: rgbData.buffer
};
}
export default {
name: "ImJoyContent",
components: { ImjoyPluginCard },
data() {
return {
loading: false,
loadedPlugins: {},
viewers: {}
};
},
computed: {
captureActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/capture`;
},
snapshotStreamUri: function() {
return `${this.$store.getters.baseUri}/api/v2/streams/snapshot`;
},
getPositionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/state/stage/position`;
},
moveActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/move`;
},
baseUri: function() {
return this.$store.getters.baseUri;
},
...mapState("imjoy", { windows: "tabs" })
},
mounted() {
const self = this;
const imjoy = new imjoyCore.ImJoy({
// We will load the ImJoy plugin base frame via http (instead of https)
// such that imjoy plugins can access insecured urls
// however, some browser features won't work (e.g. webrtc)
// unless we explicitly set `base_frame` in the plugin to
// https://lib.imjoy.io/default_base_frame.html
default_base_frame: "http://lib.imjoy.io/default_base_frame.html",
imjoy_api: {
async showDialog(plugin, config, exta_config) {
return await imjoy.pm.createWindow(plugin, config, exta_config);
},
async showSnackbar(plugin, msg, duration) {
duration = duration || 5;
UIkit.notification.closeAll();
UIkit.notification({
message: msg.slice(0, 100),
status: "primary",
pos: "bottom-center",
timeout: duration * 1000
});
},
async showMessage(plugin, msg) {
imjoy.imjoy_api.showSnackbar(plugin, msg, 5);
},
async showStatus(plugin, status) {
imjoy.imjoy_api.showSnackbar(plugin, status, 5);
},
async showProgress(plugin, p) {
p = p || 0;
if (p < 1.0) p = p * 100;
if (p > 100) p = 100;
if (p) {
self.$refs.imjoyProgressbar.style.display = "block";
self.$refs.imjoyProgressbar.value = parseInt(p);
} else {
self.$refs.imjoyProgressbar.style.display = "none";
}
}
}
});
imjoy.start({ workspace: "default" }).then(async () => {
console.log("ImJoy started");
imjoy.event_bus.on("add_window", w => {
this.addWindow(w);
});
this.setupMicroscopeAPI();
this.setupViewers();
const origin = window.location.origin;
const localPlugins = [
"OpenFlexureSnapImageTemplate",
"OpenFlexureScriptEditor",
"OpenFlexureTestMoveStage",
"ListServices"
];
for (let fname of localPlugins) {
await this.loadPlugin(`${origin}/${fname}.imjoy.html`);
}
this.setupPluginEngine();
});
this.imjoy = imjoy;
},
methods: {
setupPluginEngine() {
// setup a plugin engine for Python plugins
this.imjoy.pm
.reloadPluginRecursively({
uri:
"https://imjoy-team.github.io/jupyter-engine-manager/Jupyter-Engine-Manager.imjoy.html"
})
.then(enginePlugin => {
// TODO: currently the url query are not preserved for ImJoy
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const engine = urlParams.get("engine");
const spec = urlParams.get("spec");
if (engine) {
enginePlugin.api
.createEngine({
name: "MyCustomEngine",
nbUrl: engine,
url: engine.split("?")[0]
})
.then(() => {
console.log("Jupyter Engine connected!");
})
.catch(e => {
console.error("Failed to connect to Jupyter Engine", e);
});
} else {
enginePlugin.api
.createEngine({
name: "MyBinder Engine",
url: "https://mybinder.org",
spec: spec || "oeway/imjoy-binder-image/master"
})
.then(() => {
console.log("Binder Engine connected!");
})
.catch(e => {
console.error("Failed to connect to MyBinder Engine", e);
});
}
});
},
async setupMicroscopeAPI() {
// Here we register a set of API for controlling the microscope as a service
// For interoperatability, it might be good to reuse a subset of the function names defined in MicroManager
// See here: https://valelab4.ucsf.edu/~MM/doc/MMCore/html/class_c_m_m_core.html
const snapshotUri = this.snapshotStreamUri;
const captureActionUri = this.captureActionUri;
const modalError = this.modalError;
const getPositionAsObject = this.getStagePosition;
const setPositionAsObject = this.setStagePosition;
const getInstrumentSettings = this.getInstrumentSettings;
const setInstrumentSettings = this.setInstrumentSettings;
const runAutofocus = this.runAutofocus;
const service = {
_rintf: true, // this will make sure the function can be called multiple times
type: "#microscope-control",
name: "OpenFlexure",
lastSnapResponse: null,
snapPreviewImage() {
// The "proper" capture method is more involved - this one pulls a frame out of the preview stream
return axios
.get(snapshotUri)
.then(response => {
return response.data;
})
.catch(modalError);
},
snapImage() {
// Do capture
return axios.post(captureActionUri, {}).then(response => {
service.lastSnapResponse = response;
// TODO: send the events to flash the stream and update captures
// Flash the stream (capture animation)
//this.$root.$emit("globalFlashStream");
// Update the global capture list
//this.$root.$emit("globalUpdateCaptures");
});
//.catch(modalError);
},
async getImage() {
// FIXME: handle nicely getImage() being called before snapImage()
// Wait until the capture has completed and we can retrieve it
service.lastSnapResponse = await pollAction(service.lastSnapResponse);
let capture = service.lastSnapResponse.data.output;
// TODO: check links.download.href exists and is JPEG
let jpeg = await axios
.get(capture.links.download.href, {
responseType: "arraybuffer",
headers: {
"Content-Type": "image/jpeg"
}
})
.then(r => r.data);
console.log(
"Downloaded ",
jpeg.byteLength,
" bytes of JPEG image data"
);
return jpeg; //TODO: what is the expected output format???
},
setPosition(z) {
// The C api suggests this defaults to just Z, and can accept a "label" to
// select the stage.
return setPositionAsObject({
z: z,
absolute: true
});
},
async getPosition() {
const { z } = await getPositionAsObject();
return z;
},
async setXYPosition(x, y) {
return setPositionAsObject({
x: x,
y: y,
absolute: true
});
},
async getXYPosition() {
const { x, y } = await getPositionAsObject();
return [x, y];
},
async getXYZPosition() {
const { x, y, z } = await getPositionAsObject();
return [x, y, z];
},
async setXYZPosition(x, y, z) {
return setPositionAsObject({
x: x,
y: y,
z: z,
absolute: true
});
},
async getExposure() {
const settings = await getInstrumentSettings();
if (settings.camera.picamera) {
if (settings.camera.picamera.shutter_speed) {
return settings.camera.picamera.shutter_speed / 1000;
}
}
return -1;
},
async setExposure(exposure) {
setInstrumentSettings({
camera: {
picamera: {
shutter_speed: exposure * 1000
}
}
});
},
async fullFocus() {
return runAutofocus();
}
};
const ref = await this.imjoy.pm.registerService(
{ type: "#microscope-control", name: "OpenFlexure" },
service
);
console.log("registered service");
console.log(ref);
},
closeWindow(w) {
if (w) {
w.api.close();
this.$store.commit("imjoy/removeTab", w);
}
},
/**
* Activate a window - you can pass the window object, its name, or its id.
*/
selectWindow(window) {
for (let w of this.windows) {
if ((w === window) | (w.name === window) | (w.id === window)) {
// Make the relevant window visible by switching to its tab
this.$root.$emit("globalSwitchTab", `ImJoy-Plugin-${w.id}`);
break;
}
}
},
setWindowIconUrl(window, iconURL) {
this.$store.commit("imjoy/setTabProperty", {
tab: window,
key: "iconURL",
value: iconURL
});
},
async startKaibu() {
this.viewers.kaibu = await this.imjoy.pm.createWindow(null, {
name: "Kaibu",
src: "https://kaibu.org/#/app"
});
this.viewers.kaibu.on("close", () => {
delete this.viewers.kaibu;
});
this.setWindowIconUrl(
this.viewers.kaibu.config.id,
"https://kaibu.org/static/img/kaibu-icon.svg"
);
this.viewers.kaibu.set_mode("full");
},
async startImageJ() {
this.viewers.imagej = await this.imjoy.pm.createWindow(null, {
name: "ImageJ.JS",
src: "https://ij.imjoy.io"
});
this.viewers.imagej.on("close", () => {
delete this.viewers.imagej;
});
this.setWindowIconUrl(
this.viewers.imagej.config.id,
"https://ij.imjoy.io/assets/icons/chrome/chrome-installprocess-128-128.png"
);
// load the ITK/VTK viewer for imagej.js
this.loadPlugin(
"https://gist.githubusercontent.com/oeway/e5c980fbf6582f25fde795262a7e33ec/raw/itk-vtk-viewer-imagej.imjoy.html"
);
},
/**
* Switch to the ImageJ window, starting it if necessary.
*/
async openImageJ() {
if (!this.viewers.imagej) {
await this.startImageJ();
}
this.selectWindow("ImageJ.JS");
return this.viewers.imagej;
},
/**
* Switch to the Kaibu window, starting it if necessary.
*/
async openKaibu() {
if (!this.viewers.kaibu) {
await this.startKaibu();
}
this.selectWindow("Kaibu"); // TODO: make this behave more nicely when we have multiple Kaibu windows
return this.viewers.kaibu;
},
async setupViewers() {
this.loading = true;
// TODO: improve this using the ImJoy `api.getServices`
// See here: https://imjoy.io/docs/#/api?id=apigetservices
// The idea is that plugins can register custom services via `api.registerService`
// For example, we can have a image visualization service
// Then other plugins can use `api.getServices` to get a list of services
// And this can be used for render our "Open In ImJoy" menu
try {
const self = this;
this.$store.commit("imjoy/addOpenImageItem", {
title: "Kaibu",
async callback(name, imageUrl) {
const viewer = await self.openKaibu();
await viewer.view_image(imageUrl, { type: "2d-image", name });
await viewer.add_shapes([]);
}
});
this.$store.commit("imjoy/addOpenImageItem", {
title: "ImageJ.JS",
async callback(name, imageUrl) {
const viewer = await self.openImageJ();
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
await viewer.viewImage(buffer, {
name: name.replace(".jpeg", ".jpg")
});
}
});
this.$store.commit("imjoy/addOpenImageItem", {
title: "ImageJ.JS [ndarray]",
async callback(name, imageUrl) {
const viewer = await self.openImageJ();
const ndarray = await imageUrlToNdarray(imageUrl);
await viewer.viewImage(ndarray, {
name: name.replace(".jpeg", "")
});
}
});
this.$store.commit("imjoy/addOpenScanItem", {
title: "ImageJ.JS [stack]",
async callback(name, allURLs) {
const viewer = await self.openImageJ();
// allURLs is a list of URLs for *capture objects*, so
// first we need to retrieve them, then extract image URLs
// and finally turn image URLs into "numpy arrays" (RGB data).
const imagesAsNdarrays = await Promise.all(
allURLs.map(async url => {
const r = await axios.get(url);
return await imageUrlToNdarray(r.data.links.download.href);
})
);
// Construct an array to hold all the images, based on the first image
// TODO: assert that _rdtype is uint8, _rshape is the same, _rtype is ndarray
const shape = imagesAsNdarrays[0]._rshape;
const totalSize = imagesAsNdarrays.reduce(
(total, image) => total + image._rvalue.byteLength,
0
);
const stackData = new Uint8Array(new ArrayBuffer(totalSize));
const transferredBytes = imagesAsNdarrays.reduce((total, image) => {
console.log(`inserting image at ${total} bytes`);
// TODO: is it bad to populate stackData as a "side effect"?
stackData.set(new Uint8Array(image._rvalue), total);
//assert image._rshape == shape;
return total + image._rvalue.byteLength;
}, 0);
const stackNdarray = {
_rtype: "ndarray",
_rdtype: "uint8",
_rshape: [imagesAsNdarrays.length, shape[0], shape[1], shape[2]],
_rvalue: stackData.buffer
};
console.log(
`Stack is ${transferredBytes} and will have shape: ${stackNdarray._rshape}`
);
await viewer.viewImage(stackNdarray, {
name
});
}
});
} catch (e) {
alert(`Failed to setup viewers, error: ${e}`);
} finally {
this.loading = false;
}
},
async addWindow(w) {
// Here we can add check w.standalone if we want to allow the plugin to decide whether to open in a new tab.
this.$store.commit("imjoy/addTab", w);
await this.$nextTick();
//this.$forceUpdate();
this.selectWindow(w);
},
loadPlugin(uri) {
return new Promise((resolve, reject) => {
this.loading = true;
this.imjoy.pm
.reloadPluginRecursively({
uri
})
.then(async plugin => {
this.loadedPlugins[plugin.name] = plugin;
this.loading = false;
resolve(plugin);
})
.catch(e => {
console.error(e);
this.loading = false;
reject(e);
alert(`failed to load the plugin, error: ${e}`);
});
});
},
async runPlugin(plugin) {
await plugin.api.run();
},
loadPluginDialog() {
const uri = prompt(
"Paste the ImJoy plugin url here",
"imjoy-team/imjoy-plugins:Welcome"
);
if (uri) {
this.loadPlugin(uri);
}
},
async getStagePosition() {
return axios.get(this.getPositionUri).then(r => r.data);
},
async setStagePosition(payload) {
return axios.post(this.moveActionUri, payload).then(pollAction);
},
async getInstrumentSettings() {
return axios
.get(`${this.baseUri}/api/v2/instrument/settings/`)
.then(r => r.data);
},
async setInstrumentSettings(payload) {
return axios.put(`${this.baseUri}/api/v2/instrument/settings/`, payload);
},
runAutofocus() {
// This mechanism is also used by the keybinding for autofocus.
// It means we don't have a way to poll, but that's not a problem.
this.$root.$emit("globalFastAutofocusEvent");
}
}
};
</script>
<style scoped>
.window-container {
width: 100%;
height: 100%;
}
.imjoy-container-card {
width: 100%;
height: 100%;
}
.window-title {
height: 26px;
text-align: center;
font-size: 1.2rem;
background-color: #efefef;
color: #482727;
font-weight: 500;
}
.plugins-button {
position: absolute;
right: 1px;
height: 26px;
line-height: 11px;
}
.close-button {
position: absolute;
right: 100px;
height: 26px;
}
</style>

View file

@ -0,0 +1,179 @@
<template>
<div
v-observe-visibility="visibilityChanged"
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 -->
<div
class="uk-navbar-left uk-padding-remove-top uk-padding-remove-bottom"
>
<select v-model="filterLevel" class="uk-select">
<option v-for="level in allLevels" :key="level">{{ level }}</option>
</select>
</div>
<!-- Right side buttons -->
<div class="uk-navbar-right">
<div class="uk-grid">
<div>
<button
class="uk-button uk-button-default uk-width-1-1"
type="button"
@click="updateLogs()"
>
Refresh Logs
</button>
</div>
<div>
<a class="uk-button uk-button-default" :href="logFileURI" download
>Download Log File</a
>
</div>
</div>
</div>
</nav>
<!-- Logging items -->
<div class="uk-width-xlarge uk-align-center">
<div
v-for="item in pagedItems"
:key="item.timestamp"
uk-alert
class="logging-entry"
:class="{
'uk-alert-warning uk-alert': item.data.levelname == 'WARNING',
'uk-alert-danger uk-alert': item.data.levelname == 'ERROR'
}"
>
<b>{{ formatDateTime(item.data.created) }}</b>
<div class="logging-message">{{ formatMessage(item) }}</div>
</div>
<Paginate
v-model="page"
:page-count="numberOfPages"
:page-range="3"
:margin-pages="1"
:container-class="'uk-pagination uk-flex-center'"
:prev-text="'Prev'"
:next-text="'Next'"
:page-class="'page-item'"
:active-class="'uk-active'"
:disabled-class="'uk-disabled'"
:click-handler="scrollToTop()"
>
</Paginate>
</div>
</div>
</template>
<script>
import axios from "axios";
import Paginate from "vuejs-paginate";
export default {
name: "LoggingContent",
components: {
Paginate
},
data: function() {
return {
maxitems: 20,
page: 1,
logs: [],
allLevels: ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
filterLevel: "WARNING"
};
},
computed: {
filteredLevels: function() {
let cutoffIndex = this.allLevels.indexOf(this.filterLevel);
return this.allLevels.slice(cutoffIndex, -1);
},
filteredItems: function() {
var items = [];
for (var item of this.logs) {
// Add to capture list if matched
if (this.filteredLevels.includes(item.data.levelname)) {
items.push(item);
}
}
return items;
},
loggingUri: function() {
return `${this.$store.getters.baseUri}/api/v2/events/logging`;
},
logFileURI: function() {
return `${this.$store.getters.baseUri}/api/v2/log`;
},
pagedItems: function() {
let startIndex = (this.page - 1) * this.maxitems;
return this.filteredItems.slice(startIndex, startIndex + this.maxitems);
},
numberOfPages: function() {
return Math.floor(this.filteredItems.length / this.maxitems);
}
},
mounted() {
// Update on mount (does nothing if not connected)
this.updateLogs();
},
methods: {
scrollToTop() {
const el = document.querySelector("#container-left");
if (el) {
el.scrollTop = 0;
}
},
visibilityChanged(isVisible) {
if (isVisible) {
this.updateLogs();
}
},
updateLogs: function() {
axios
.get(this.loggingUri)
.then(response => {
this.logs = response.data.reverse();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
formatDateTime: function(isoDateTimeString) {
let date = new Date(isoDateTimeString);
return date.toLocaleDateString() + " " + date.toLocaleTimeString();
},
formatMessage: function(item) {
return item.data.levelname + ": " + item.data.message;
}
}
};
</script>
<style lang="less" scoped>
.logging-navbar {
border-width: 0 0 1px 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
margin-bottom: 30px;
height: 80px;
}
.logging-entry {
white-space: break-spaces;
}
.logging-message {
font-family: monospace;
}
</style>

View file

@ -0,0 +1,425 @@
<template>
<div id="paneNavigate" class="uk-padding-small">
<div v-if="setPosition">
<ul uk-accordion="multiple: true">
<li>
<a class="uk-accordion-title" href="#">Configure</a>
<div class="uk-accordion-content">
<b>STEP SIZE</b>
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="stepSize.x"
class="uk-input uk-form-small"
type="number"
name="inputStepXy"
/>
</div>
<label class="uk-margin-small-right"
><input
v-model="invert.x"
class="uk-checkbox"
type="checkbox"
/>
Invert x</label
>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="stepSize.y"
class="uk-input uk-form-small"
type="number"
name="inputStepY"
/>
</div>
<label class="uk-margin-small-right"
><input
v-model="invert.y"
class="uk-checkbox"
type="checkbox"
/>
Invert y</label
>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="stepSize.z"
class="uk-input uk-form-small"
type="number"
name="inputStepZz"
/>
</div>
<label
><input
v-model="invert.z"
class="uk-checkbox"
type="checkbox"
/>
Invert z</label
>
</div>
</div>
<button
class="uk-button uk-button-default uk-margin uk-width-1-1"
@click="zeroRequest()"
>
Zero coordinates
</button>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Move-to</a>
<div class="uk-accordion-content">
<form @submit.prevent="handleSubmit">
<!-- Text boxes to set and view position -->
<div class="uk-grid-small uk-child-width-1-3" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="setPosition.x"
class="uk-input uk-form-small"
type="number"
name="inputPositionX"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="setPosition.y"
class="uk-input uk-form-small"
type="number"
name="inputPositionY"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="setPosition.z"
class="uk-input uk-form-small"
type="number"
name="inputPositionZx"
/>
</div>
</div>
</div>
<p>
<button
type="submit"
class="uk-button uk-button-default uk-float-right uk-width-1-1"
>
Move
</button>
</p>
</form>
</div>
</li>
<!--Show autofocus if default plugin is enabled-->
<li v-show="fastAutofocusUri || normalAutofocusUri" class="uk-open">
<a class="uk-accordion-title" href="#">Autofocus</a>
<div class="uk-accordion-content">
<div class="uk-grid-small uk-child-width-expand" uk-grid>
<div v-show="!isAutofocusing || isAutofocusing == 1">
<taskSubmitter
v-if="fastAutofocusUri"
:submit-url="fastAutofocusUri"
:submit-data="{ dz: 2000 }"
:submit-label="'Fast'"
:button-primary="false"
:submit-on-event="'globalFastAutofocusEvent'"
@taskStarted="isAutofocusing = 1"
@finished="isAutofocusing = 0"
@error="modalError"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 2">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-90, -60, -30, 0, 30, 60, 90] }"
:submit-label="'Medium'"
:button-primary="false"
@taskStarted="isAutofocusing = 2"
@finished="isAutofocusing = 0"
@error="modalError"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 3">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-30, -20, -10, 0, 10, 20, 30] }"
:submit-label="'Fine'"
:button-primary="false"
@taskStarted="isAutofocusing = 3"
@finished="isAutofocusing = 0"
@error="modalError"
></taskSubmitter>
</div>
</div>
</div>
</li>
</ul>
</div>
<div v-else class="uk-text-warning">
<p>Stage positioning is disabled since no stage is connected.</p>
<p>
Please check all data and power connections to your motor controller
board.
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "PaneNavigate",
components: {
taskSubmitter
},
data: function() {
return {
stepXy: 200,
stepZz: 50,
stepSize: {
x: 200,
y: 200,
z: 50
},
invert: {
x: false,
y: false,
z: false
},
setPosition: null,
isAutofocusing: 0,
moveLock: false,
fastAutofocusUri: null,
normalAutofocusUri: null,
moveInImageCoordinatesUri: null
};
},
computed: {
moveActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/move`;
},
zeroActionUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/stage/zero`;
},
positionStatusUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/state/stage/position`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
watch: {
stepSize: {
deep: true,
handler() {
this.setLocalStorageObj("navigation_stepSize", this.stepSize);
}
},
invert: {
deep: true,
handler() {
this.setLocalStorageObj("navigation_invert", this.invert);
}
}
},
mounted() {
// Reload saved settings
this.stepSize =
this.getLocalStorageObj("navigation_stepSize") || this.stepSize;
this.invert = this.getLocalStorageObj("navigation_invert") || this.invert;
// A global signal listener to perform a move action
this.$root.$on("globalMoveEvent", (x, y, z, absolute) => {
this.moveRequest(x, y, z, absolute);
});
// A global signal listener to perform a move action in pixels
this.$root.$on("globalMoveInImageCoordinatesEvent", (x, y, absolute) => {
this.moveInImageCoordinatesRequest(x, y, absolute);
});
// A global signal listener to perform a move in multiples of a step size
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
this.moveRequest(
x_steps * this.stepSize.x * (this.invert.x ? -1 : 1),
y_steps * this.stepSize.y * (this.invert.y ? -1 : 1),
z_steps * this.stepSize.z * (this.invert.z ? -1 : 1),
false
);
});
// Update the current position in text boxes
this.updatePosition();
// Look for autofocus plugin
this.updateAutofocusUri();
this.updateMoveInImageCoordinatesUri();
},
beforeDestroy() {
// Remove global signal listener to perform a move action
this.$root.$off("globalMoveEvent");
this.$root.$off("globalMoveInImageCoordinatesEvent");
this.$root.$off("globalMoveStepEvent");
},
methods: {
handleSubmit: function() {
this.moveRequest(
this.setPosition.x,
this.setPosition.y,
this.setPosition.z,
true
);
},
moveRequest: function(x, y, z, absolute) {
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
// Send move request
axios
.post(this.moveActionUri, {
x: x,
y: y,
z: z,
absolute: absolute
})
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
}
},
moveInImageCoordinatesRequest: function(x, y) {
// If not movement-locked
if (!this.moveLock) {
// Lock move requests
this.moveLock = true;
if (!this.moveInImageCoordinatesUri) {
this.modalError(
"Moving in image coordinates is not supported - you will need to upgrade your microscope's software."
);
}
// Send move request
axios
.post(this.moveInImageCoordinatesUri, {
x: y, // NB the coordinates are numpy/PIL style, meaning X and Y are swapped.
y: x
})
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
})
.then(() => {
this.moveLock = false; // Release the move lock
});
}
},
zeroRequest: function() {
// Send move request
axios
.post(this.zeroActionUri)
.then(() => {
this.updatePosition(); // Update the position in text boxes
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updatePosition: function() {
axios
.get(this.positionStatusUri)
.then(response => {
this.setPosition = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateAutofocusUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.autofocus"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.fastAutofocusUri = foundExtension.links.fast_autofocus.href;
this.normalAutofocusUri = foundExtension.links.autofocus.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateMoveInImageCoordinatesUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
if (foundExtension) {
// Get plugin action link
this.moveInImageCoordinatesUri =
foundExtension.links.move_in_image_coordinates.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>

View file

@ -0,0 +1,25 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneNavigate />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneNavigate from "./navigateComponents/paneNavigate";
import streamDisplay from "./streamContent.vue";
export default {
name: "NavigateContent",
components: {
paneNavigate,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,167 @@
<template>
<div
id="CSMSettings"
class="uk-grid uk-grid-divider uk-child-width-expand"
uk-grid
>
<div class="uk-width-large">
<h3>Camera/stage mapping</h3>
<p>
Camera/stage mapping allows the stage to move relative to the camera
view. This enables functions like click-to-move, and more precise tile
scans.
</p>
<CSMCalibrationSettings />
</div>
<div id="mini-stream">
<miniStreamDisplay />
</div>
</div>
</template>
<script>
import axios from "axios";
import CSMCalibrationSettings from "./CSMSettingsComponents/CSMCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
// Export main app
export default {
name: "CSMSettings",
components: {
CSMCalibrationSettings,
miniStreamDisplay
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateSettings();
this.updateRecalibrationLinks();
},
methods: {
updateSettings: function() {
// Update links
axios
.get(this.settingsUri)
.then(response => {
this.settings =
response.data.extensions["org.openflexure.camera_stage_mapping"];
})
.catch(error => {
this.modalError(error); // Let mixin handle error
this.settings = {};
});
},
updateCalibrationDataAvailability: function() {
if ("get_calibration" in this.recalibrationLinks) {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (Object.keys(response.data).length === 0) {
this.dataAvailable = false;
} else {
this.dataAvailable = true;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
getCalibrationData: function() {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (response.data != {}) {
const data = JSON.stringify(response.data);
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "csm_calibration.json");
document.body.appendChild(link);
link.click();
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
// if camera-stage mapping extension is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
// Update whether calibration data is available
this.updateCalibrationDataAvailability();
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished stage-to-camera calibration.");
// Update local settings
this.updateSettings();
},
onRecalibrateError: function(error) {
this.modalError(error); // Let mixin handle error
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
#mini-stream {
min-width: 300px;
max-width: 600px;
text-align: center;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div id="CSMCalibrationSettings">
<!--Show auto calibrate if default plugin is enabled-->
<div v-if="'calibrate_xy' in recalibrationLinks" class="uk-margin-small">
<taskSubmitter
:button-primary="true"
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'
"
:submit-url="recalibrationLinks.calibrate_xy.href"
:submit-label="'Auto-Calibrate using camera'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<button
v-if="'get_calibration' in recalibrationLinks"
v-show="dataAvailable && showExtraSettings"
type="button"
class="uk-button uk-button-default uk-width-1-1"
@click="getCalibrationData()"
>
Download calibration data
</button>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "CSMCalibrationSettings",
components: {
taskSubmitter
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
settings: null,
recalibrationLinks: {},
isCalibrating: false,
dataAvailable: false
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateSettings();
this.updateRecalibrationLinks();
},
methods: {
updateSettings: function() {
// Update links
axios
.get(this.settingsUri)
.then(response => {
this.settings =
response.data.extensions["org.openflexure.camera_stage_mapping"];
})
.catch(error => {
this.modalError(error); // Let mixin handle error
this.settings = {};
});
},
updateCalibrationDataAvailability: function() {
if ("get_calibration" in this.recalibrationLinks) {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (Object.keys(response.data).length === 0) {
this.dataAvailable = false;
} else {
this.dataAvailable = true;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
getCalibrationData: function() {
axios
.get(this.recalibrationLinks.get_calibration.href)
.then(response => {
if (response.data != {}) {
const data = JSON.stringify(response.data);
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "csm_calibration.json");
document.body.appendChild(link);
link.click();
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.camera_stage_mapping"
);
// if camera-stage mapping extension is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
// Update whether calibration data is available
this.updateCalibrationDataAvailability();
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished stage-to-camera calibration.");
// Update local settings
this.updateSettings();
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,50 @@
<template>
<div id="appSettings" class="uk-width-large">
<h3>Appearance</h3>
<p>
<label>
Theme
<select v-model="appTheme" class="uk-select">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</label>
</p>
</div>
</template>
<script>
// Export main app
export default {
name: "AppSettings",
data: function() {
return {};
},
computed: {
appTheme: {
get() {
return this.$store.state.appTheme;
},
set(value) {
this.$store.commit("changeAppTheme", value);
}
}
},
watch: {
appTheme: function() {
this.setLocalStorageObj("appTheme", this.appTheme);
}
},
mounted() {
// Try loading settings from localStorage. If null, don't change.
this.appTheme = this.getLocalStorageObj("appTheme") || this.appTheme;
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,327 @@
<template>
<div id="cameraSettings">
<div class="uk-grid uk-grid-divider uk-child-width-expand" uk-grid>
<div class="uk-width-large">
<h3>Manual camera settings</h3>
<form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom">
<ul uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">Pi Camera Settings</a>
<div class="uk-accordion-content">
<div v-if="picamera.shutter_speed !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Exposure time</label
>
<div class="uk-form-controls">
<input
v-model="picamera.shutter_speed"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div v-if="picamera.analog_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Analogue gain</label
>
<div class="uk-form-controls">
<input
v-model="picamera.analog_gain"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
<div v-if="picamera.digital_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Digital gain</label
>
<div class="uk-form-controls">
<input
v-model="picamera.digital_gain"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
<div v-if="picamera.awb_gains !== undefined">
<label class="uk-form-label" for="form-stacked-text">
White Balance gains
</label>
<div class="uk-form-controls">
<label class="uk-form-label">R:</label>
<input
v-model="picamera.awb_gains[0]"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
<label class="uk-form-label">B:</label>
<input
v-model="picamera.awb_gains[1]"
class="uk-input uk-form-small"
type="number"
step="0.0000000000000001"
/>
</div>
</div>
</div>
</li>
<li class="uk-open">
<a class="uk-accordion-title" href="#">Image Quality</a>
<div class="uk-accordion-content">
<div class="uk-child-width-1-2" uk-grid>
<div v-if="jpeg_quality !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>JPEG capture quality (%)</label
>
<div class="uk-form-controls">
<input
v-model="jpeg_quality"
class="uk-input uk-form-small"
type="number"
step="1"
/>
</div>
</div>
<div v-if="stream_resolution !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Stream resolution</label
>
<select
v-model="stream_resolution"
class="uk-select uk-form-small"
>
<option
v-for="option in resolutionOptions"
:key="option.value[0]"
:value="option.value"
>
{{ option.text }}
</option>
</select>
</div>
</div>
</div>
</li>
<li>
<a class="uk-accordion-title" href="#">Advanced</a>
<div class="uk-accordion-content">
<div v-if="mjpeg_bitrate !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Camera bitrate</label
>
<select
v-model="mjpeg_bitrate"
class="uk-select uk-form-small"
>
<option
v-for="option in bitrateOptions"
:key="option.value"
:value="option.value"
>
{{ option.text }}
</option>
</select>
<p class="uk-margin-remove">
This option sets the target bitrate for camera
recordings.<br />
<b
>Limiting bitrate may impact fast-autofocus
reliability.</b
>
However is guaranteed to fix the live stream disappearing
for some highly detailed samples.
</p>
</div>
<div
v-if="picamera.framerate !== undefined"
class="uk-margin-top"
>
<label class="uk-form-label" for="form-stacked-text"
>Camera framerate</label
>
<select
v-model="picamera.framerate"
class="uk-select uk-form-small"
>
<option
v-for="option in framerateOptions"
:key="option.value"
:value="option.value"
>
{{ option.text }}
</option>
</select>
<p class="uk-margin-remove">
This option sets the framerate of the Pi Camera video
encoder. The actual stream framerate is determined by
network speed, and is limited by the encoder framerate.<br />
<b
>Lowering framerate may impact fast-autofocus
accuracy.</b
>
</p>
</div>
</div>
</li>
</ul>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
<h3>Automatic calibration</h3>
<cameraCalibrationSettings></cameraCalibrationSettings>
</div>
<div id="mini-stream">
<miniStreamDisplay />
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
// Export main app
export default {
name: "CameraSettings",
components: {
cameraCalibrationSettings,
miniStreamDisplay
},
data: function() {
return {
picamera: {
shutter_speed: undefined,
analog_gain: undefined,
digital_gain: undefined,
framerate: undefined,
awb_gains: undefined
},
mjpeg_bitrate: undefined,
stream_resolution: undefined,
jpeg_quality: undefined,
bitrateOptions: [
{ text: "Maximum (unlimited)", value: -1 },
{ text: "High (25Mbps)", value: 25000000 },
{ text: "Normal (17Mbps)", value: 17000000 },
{ text: "Low (5Mbps)", value: 5000000 },
{ text: "Very low (2.5Mbps)", value: 2500000 }
],
resolutionOptions: [
{ text: "Higher (832, 624)", value: [832, 624] },
{ text: "Normal (640, 480)", value: [640, 480] }
],
framerateOptions: [
{ text: "Normal (30fps)", value: 30 },
{ text: "Low (15fps)", value: 15 },
{ text: "Very low (10fps)", value: 10 }
]
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
const cameraSettings = response.data.camera;
// Get base camera settings
this.mjpeg_bitrate = cameraSettings.mjpeg_bitrate;
this.jpeg_quality = cameraSettings.jpeg_quality;
this.stream_resolution = cameraSettings.stream_resolution;
// Get Pi Camera settings if they exist
if (cameraSettings.picamera) {
this.picamera.analog_gain = cameraSettings.picamera.analog_gain;
this.picamera.digital_gain = cameraSettings.picamera.digital_gain;
this.picamera.shutter_speed = cameraSettings.picamera.shutter_speed;
this.picamera.framerate = cameraSettings.picamera.framerate;
this.picamera.awb_gains = cameraSettings.picamera.awb_gains;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
// We have to use parseInt/parseFloat because JS sometimes seems to
// make the numbers be strings... TypeScript would solve this...
var payload = {
camera: {
mjpeg_bitrate: parseInt(this.mjpeg_bitrate),
jpeg_quality: parseInt(this.jpeg_quality),
stream_resolution: this.stream_resolution,
picamera: {
shutter_speed: parseFloat(this.picamera.shutter_speed),
analog_gain: parseFloat(this.picamera.analog_gain),
digital_gain: parseFloat(this.picamera.digital_gain),
framerate: parseInt(this.picamera.framerate),
awb_gains: [
parseFloat(this.picamera.awb_gains[0]),
parseFloat(this.picamera.awb_gains[1])
]
}
}
};
console.log(payload);
// Send request
axios
.put(this.settingsUri, payload)
.then(() => {
return new Promise(r => setTimeout(r, 500));
}) // why is there no built-in for this??!
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Camera settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style lang="less">
#mini-stream {
min-width: 300px;
max-width: 600px;
text-align: center;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
}
</style>

View file

@ -0,0 +1,179 @@
<template>
<div>
<!--Show auto calibrate if default plugin is enabled-->
<div v-if="'recalibrate' in recalibrationLinks" class="uk-margin-small">
<taskSubmitter
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'Start recalibration? This may take a while, and the microscope will be locked during this time.'
"
:submit-url="recalibrationLinks.recalibrate.href"
:submit-label="'Full Auto-Calibrate'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_exposure_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter
:can-terminate="false"
:requires-confirmation="false"
:submit-url="recalibrationLinks.auto_exposure_from_raw.href"
:submit-label="'Auto gain &amp; shutter speed'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_white_balance_from_raw' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter
:can-terminate="false"
:requires-confirmation="false"
:submit-url="recalibrationLinks.auto_white_balance_from_raw.href"
:submit-label="'Auto white balance'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div
v-if="'auto_lens_shading_table' in recalibrationLinks"
class="uk-margin-small"
>
<taskSubmitter
:can-terminate="false"
:requires-confirmation="true"
:confirmation-message="
'Is the microscope looking at an evenly illuminated, empty field of view? ' +
'If not, the current image will show through in any images captured afterwards.'
"
:submit-url="recalibrationLinks.auto_lens_shading_table.href"
:submit-label="'Auto flat field correction'"
@response="onRecalibrateResponse"
@error="modalError"
>
</taskSubmitter>
</div>
<div v-show="showExtraSettings" class="uk-child-width-expand">
<button
v-if="'flatten_lens_shading_table' in recalibrationLinks"
class="uk-button uk-button-danger uk-width-1-1"
@click="flattenLensShadingTableRequest"
>
Disable flat field correction
</button>
</div>
<div v-if="LstDownloadEnabled">
<a
class="uk-button uk-button-default uk-width-large uk-margin-small-top uk-align-center"
:href="LstDownloadUri"
download
>Download Lens-Shading Table</a
>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "CameraCalibrationSettings",
components: {
taskSubmitter
},
props: {
showExtraSettings: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
recalibrationLinks: {},
isCalibrating: false,
LstDownloadEnabled: false
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
LstDownloadUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/camera/lst`;
}
},
mounted() {
this.updateRecalibrationLinks();
this.checkLstDownload();
},
methods: {
updateRecalibrationLinks: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.calibration.picamera"
);
// if AutocalibrationPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.recalibrationLinks = foundExtension.links;
} else {
this.recalibrationLinks = {};
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
checkLstDownload: function() {
axios
.get(this.LstDownloadUri) // Get a list of plugins
.then(response => {
if (response.status === 200) {
this.LstDownloadEnabled = true;
} else {
this.LstDownloadEnabled = false;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished recalibration.");
},
flattenLensShadingTableRequest: function() {
axios.post(this.recalibrationLinks.flatten_lens_shading_table.href);
},
deleteLensShadingTableRequest: function() {
axios.post(this.recalibrationLinks.delete_lens_shading_table.href);
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,115 @@
<template>
<div id="appSettings" class="uk-width-large">
<h3>Additional features</h3>
<p class="uk-margin-small">
<label
><input v-model="IHIEnabled" class="uk-checkbox" type="checkbox" />
Enable IHI Slide Scan</label
>
</p>
<p class="uk-margin-small">
Enabling IHI Slide Scan will add a new tab designed to simplify acquiring
blood smear tile scans. <br />
While this may be useful for other applications, it is primarily designed
for use in clinics acquiring blood smear samples using a 100x
oil-immersion objective, with a Raspberry Pi Camera v2.
</p>
<p class="uk-margin-small" :class="{ 'uk-text-muted': !imjoyPermitted }">
<label :disabled="!imjoyPermitted">
<input
v-model="imjoyEnabled"
class="uk-checkbox"
type="checkbox"
:disabled="!imjoyPermitted"
/>
Enable ImJoy plugin engine
</label>
</p>
<p v-if="imjoyPermitted" class="uk-margin-small">
<a href="https://imjoy.io/">ImJoy</a> enables integration with a wide
variety of microscopy and image analysis applications, including ImageJ.JS
and Kaibu. Support for ImJoy within the OFM software is currently
experimental, and it may slow down loading of the application.
</p>
<p v-if="!imjoyPermitted" class="uk-margin-small uk-text-muted">
ImJoy plugins are disabled in this build of the OpenFlexure software.
</p>
<p class="uk-margin-small">
<label
><input v-model="galleryEnabled" class="uk-checkbox" type="checkbox" />
Enable Gallery</label
>
</p>
<p class="uk-margin-small">
The "gallery" tab can cause performance issues; un-tick the box above to
disable it.
</p>
</div>
</template>
<script>
// Export main app
export default {
name: "FeaturesSettings",
data: function() {
return {};
},
computed: {
IHIEnabled: {
get() {
return this.$store.state.IHIEnabled;
},
set(value) {
this.$store.commit("changeIHIEnabled", value);
}
},
imjoyEnabled: {
get() {
return this.$store.state.imjoyEnabled;
},
set(value) {
this.$store.commit("changeImjoyEnabled", value);
}
},
galleryEnabled: {
get() {
return this.$store.state.galleryEnabled;
},
set(value) {
this.$store.commit("changeGalleryEnabled", value);
}
},
imjoyPermitted: function() {
// ImJoy may be disabled using an environment variable.
// If this function returns false, it is never possible to
// use ImJoy, so we should gray out the option.
return process.env.VUE_APP_ENABLE_IMJOY === "true";
}
},
watch: {
IHIEnabled: function() {
this.setLocalStorageObj("IHIEnabled", this.IHIEnabled);
},
imjoyEnabled: function() {
this.setLocalStorageObj("imjoyEnabled", this.imjoyEnabled);
},
galleryEnabled: function() {
this.setLocalStorageObj("galleryEnabled", this.galleryEnabled);
}
},
mounted() {
// Try loading settings from localStorage. If null, don't change.
this.IHIEnabled = this.getLocalStorageObj("IHIEnabled") || this.IHIEnabled;
this.imjoyEnabled =
this.getLocalStorageObj("imjoyEnabled") || this.imjoyEnabled;
this.galleryEnabled =
this.getLocalStorageObj("galleryEnabled") || this.galleryEnabled;
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,89 @@
<template>
<div v-if="settings" id="microscopeSettings" class="uk-width-large">
<form @submit.prevent="applySettingsRequest">
<div>
<label class="uk-form-label" for="form-stacked-text"
>Microscope name</label
>
<input
v-model="settings.name"
class="uk-input uk-width-1-1 uk-form-small"
name="inputFilename"
placeholder="Leave blank for default"
/>
</div>
<br />
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "MicroscopeSettings",
data: function() {
return {
settings: null
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
}
},
mounted() {
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
var payload = {
name: this.settings.name
};
// Send request to update config
axios
.put(this.settingsUri, payload)
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Microscope settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
};
</script>
<style lang="less">
.center-spinner {
margin-left: auto;
margin-right: auto;
}
</style>

View file

@ -0,0 +1,195 @@
<template>
<div id="stageSettings" class="uk-width-large">
<div v-if="stageType === 'MissingStage'" class="uk-text-danger">
<b>No stage connected</b>
</div>
<div v-else>
<h3>Stage settings</h3>
<form @submit.prevent="setStageType">
<div class="uk-margin-small-bottom">
<h4>Stage geometry</h4>
<select v-model="stageType" class="uk-select uk-margin-small-top">
<option value="SangaStage">SangaStage (Standard)</option>
<option value="SangaDeltaStage"> SangaStage (Delta)</option>
</select>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Change stage geometry
</button>
</div>
</form>
<form @submit.prevent="applySettingsRequest">
<div class="uk-margin-small-bottom">
<h4>Backlash compensation</h4>
<p class="uk-margin-remove">
Backlash compentation causes movements to overshoot by that number
of motor steps, reducing the effect of mechanical backlash.
</p>
<div
class="uk-grid-small uk-child-width-1-3 uk-margin-small-bottom"
uk-grid
>
<div>
<label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls">
<input
v-model="backlash.x"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls">
<input
v-model="backlash.y"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls">
<input
v-model="backlash.z"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
</div>
<h4>Settle time</h4>
<div v-if="settle_time !== undefined">
<p class="uk-margin-small-bottom">
When moving or scanning, captures will be delayed by the settle
time (in seconds) to reduce motion blur.
</p>
<div class="uk-form-controls">
<input
v-model="settle_time"
class="uk-input uk-form-small"
type="number"
step="0.1"
/>
</div>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</div>
</form>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "StageSettings",
components: {},
data: function() {
return {
stageType: "MissingStage",
backlash: {
x: undefined,
y: undefined,
z: undefined
},
settle_time: undefined
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
stageTypeUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/stage/type`;
}
},
mounted() {
this.getStageType();
this.updateSettings();
},
methods: {
updateSettings: function() {
axios
.get(this.settingsUri)
.then(response => {
const stageSettings = response.data.stage;
this.backlash.x = stageSettings.backlash.x;
this.backlash.y = stageSettings.backlash.y;
this.backlash.z = stageSettings.backlash.z;
this.settle_time = stageSettings.settle_time;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applySettingsRequest: function() {
var payload = {
stage: {
backlash: this.backlash
}
};
// Send request to update settings
axios
.put(this.settingsUri, payload)
.then(() => {
// Update local settings
this.updateSettings();
this.modalNotify("Stage settings applied.");
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getStageType: function() {
axios
.get(this.stageTypeUri)
.then(response => {
this.stageType = response.data;
})
.catch(error => {
this.modalError(error);
});
},
setStageType: function() {
axios
.put(this.stageTypeUri, this.stageType, {
headers: {
"Content-Type": "application/json"
}
})
.then(response => {
this.stageType = response.data;
this.modalNotify("Stage geometry changed.");
})
.catch(error => {
this.modalError(error);
});
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,132 @@
<template>
<div id="streamSettings" class="uk-width-large">
<div>
<h3>Stream settings</h3>
<label
><input v-model="disableStream" class="uk-checkbox" type="checkbox" />
Disable web stream</label
>
<p class="uk-margin-small">
This will disable the embedded web stream of the camera.
</p>
</div>
<br />
<div>
<h3>Microscope display output</h3>
<div uk-grid>
<div>
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
><input
v-model="autoGpuPreview"
class="uk-checkbox"
type="checkbox"
/>
Enable GPU preview</label
>
</div>
<div>
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
><input v-model="trackWindow" class="uk-checkbox" type="checkbox" />
Track window</label
>
</div>
</div>
<p>
Enable GPU preview turns on a low-latency camera preview drawn directly
to the Raspberry Pi display output.
</p>
<p class="uk-margin-small">
<b
>Content, such as your mouse, won't be visible behind this preview.</b
>
Track Window will attempt to resize the GPU preview based on the current
window size, however this is often imperfect and cannot track window
movement.
</p>
</div>
</div>
</template>
<script>
// Export main app
export default {
name: "StreamSettings",
data: function() {
return {};
},
computed: {
disableStream: {
get() {
return this.$store.state.disableStream;
},
set(value) {
this.$store.commit("changeDisableStream", value);
}
},
autoGpuPreview: {
get() {
return this.$store.state.autoGpuPreview;
},
set(value) {
// NB the stream viewer watches the store, and is
// responsible for making the request that switches
// GPU preview on/off
// see streamContent.vue
this.$store.commit("changeAutoGpuPreview", value);
}
},
trackWindow: {
get() {
return this.$store.state.trackWindow;
},
set(value) {
this.$store.commit("changeTrackWindow", value);
}
}
},
watch: {
// Cache the stream settings to local storage for persistence
// (the next 3 functions all relate to this)
disableStream: function(newValue) {
this.setLocalStorageObj("disableStream", newValue);
},
autoGpuPreview: function(newValue) {
this.setLocalStorageObj("autoGpuPreview", newValue);
},
trackWindow: function(newValue) {
this.setLocalStorageObj("trackWindow", newValue);
}
},
created() {
// Apply sensible defaults for stream settings, depending on
// whether we're connecting locally or remotely, respecting
// the settings that were cached previously.
const localMode = ["localhost", "0.0.0.0", "127.0.0.1", "[::1]"].includes(
window.location.hostname
);
const localDefaults = {
disableStream: true,
autoGpuPreview: true,
trackWindow: true
};
for (let k in localDefaults) {
if (localStorage.getItem(k) !== null) {
this[k] = this.getLocalStorageObj(k);
} else if (localMode) {
console.log(`${k} set to default value for a local connection`);
this[k] = localDefaults[k];
}
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,218 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="settings-nav">
<ul class="uk-nav uk-nav-default">
<li class="uk-nav-header">Application settings</li>
<li>
<tabIcon
id="settings-display-icon"
tab-i-d="display"
:show-title="false"
:show-tooltip="false"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
Display
</tabIcon>
</li>
<li>
<tabIcon
id="settings-features-icon"
tab-i-d="features"
:show-title="false"
:show-tooltip="false"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
Features
</tabIcon>
</li>
<li class="uk-nav-header">Microscope settings</li>
<li>
<tabIcon
id="settings-camera-icon"
tab-i-d="camera"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Camera
</tabIcon>
</li>
<li>
<tabIcon
id="settings-stage-icon"
tab-i-d="stage"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Stage
</tabIcon>
</li>
<li>
<tabIcon
id="settings-mapping-icon"
tab-i-d="mapping"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
Camera/stage mapping
</tabIcon>
</li>
<li>
<tabIcon
id="settings-microscope-icon"
tab-i-d="microscope"
:show-title="false"
:show-tooltip="false"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
General
</tabIcon>
</li>
</ul>
</div>
<div class="view-component uk-width-expand uk-padding-small">
<tabContent
tab-i-d="display"
:require-connection="false"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<appSettings />
<streamSettings />
</div>
</tabContent>
<tabContent
tab-i-d="features"
:require-connection="false"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<featuresSettings />
</div>
</tabContent>
<tabContent
tab-i-d="camera"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<cameraSettings />
</div>
</tabContent>
<tabContent
tab-i-d="stage"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<stageSettings />
</div>
</tabContent>
<tabContent
tab-i-d="mapping"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<CSMSettings />
</div>
</tabContent>
<tabContent
tab-i-d="microscope"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<h3>Microscope settings</h3>
<microscopeSettings />
</div>
</tabContent>
</div>
</div>
</template>
<script>
import streamSettings from "./settingsComponents/streamSettings.vue";
import microscopeSettings from "./settingsComponents/microscopeSettings.vue";
import cameraSettings from "./settingsComponents/cameraSettings.vue";
import appSettings from "./settingsComponents/appSettings.vue";
import featuresSettings from "./settingsComponents/featuresSettings.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";
// Export main app
export default {
name: "SettingsContent",
components: {
streamSettings,
cameraSettings,
stageSettings,
microscopeSettings,
CSMSettings,
appSettings,
featuresSettings,
tabIcon,
tabContent
},
data: function() {
return {
selected: "display",
currentTab: "display"
};
},
methods: {
setTab: function(event, tab) {
if (!(this.currentTab == tab)) {
this.currentTab = tab;
}
}
}
};
</script>
<style lang="less" scoped>
// Custom UIkit CSS modifications
@import "../../assets/less/theme.less";
.settings-nav {
overflow-y: auto;
overflow-x: hidden;
width: 250px;
padding: 10px;
background-color: rgba(180, 180, 180, 0.03);
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.settings-nav li > a {
padding-left: 6px !important;
border-radius: @button-border-radius;
}
</style>

View file

@ -0,0 +1,318 @@
<template>
<div class="uk-padding-small">
<div v-show="!scanUri">No scan extension found</div>
<div v-show="scanUri">
<form @submit.prevent @keyup.enter="increment()">
<div v-show="stepValue == 0" id="step-user">
<h3>User information (1/4)</h3>
<label for="username">Your name:</label>
<input
id="username"
v-model="username"
class="uk-input"
type="text"
name="username"
required
/>
<br />
<label for="currentTime">Current time (check this is correct):</label>
<input
id="currentTime"
v-model="currentTimeForm"
class="uk-input"
type="datetime-local"
name="currentTime"
/>
</div>
<div v-show="stepValue == 1" id="step-patient">
<h3>Patient information (2/4)</h3>
<label for="patientID">Patient ID:</label>
<input
id="patientID"
v-model="patientID"
class="uk-input"
type="text"
name="patientID"
required
/>
<br />
</div>
<div v-show="stepValue == 2" id="step-sample">
<h3>Sample information (3/4)</h3>
<label>Sample type:</label>
<br />
<input
id="typeThin"
v-model="sampleType"
class="uk-radio"
type="radio"
value="thin"
/>
<label for="typeThin">Thin smear</label>
<br />
<input
id="typeThick"
v-model="sampleType"
class="uk-radio"
type="radio"
value="thick"
/>
<label for="typeThick">Thick smear</label>
<br />
</div>
<div v-show="stepValue == 3" id="step-scan">
<h3>Start scan (4/4)</h3>
<label>Check details:</label>
<p>
<b>Your name:</b>
{{ username }}
</p>
<p>
<b>Patient ID:</b>
{{ patientID }}
</p>
<p>
<b>Sample type:</b>
{{ sampleType }}
</p>
</div>
</form>
<div id="form-stepper">
<p class="warning">{{ message }}</p>
<taskSubmitter
v-if="scanUri"
v-show="stepValue == 3"
:submit-url="scanUri"
:submit-data="payload"
submit-label="Start scan"
@submit="scanRunning = true"
@response="scanRunning = false"
@error="onScanError"
></taskSubmitter>
<br />
<div v-show="!scanRunning" class="grid-container">
<button
class="uk-button uk-button-primary"
:disabled="stepValue <= 0"
type="button"
@click="decrement()"
>
Previous
</button>
<button
class="uk-button uk-button-primary"
:disabled="stepValue >= 3"
type="button"
@click="increment()"
>
Next
</button>
</div>
<p>
<button
v-show="stepValue == 3 && !scanRunning"
class="uk-button uk-button-danger uk-width-1-1"
:disabled="scanRunning"
type="button"
@click="restart()"
>
Restart
</button>
</p>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
export default {
components: {
taskSubmitter
},
data: function() {
return {
scanUri: null,
stepValue: 0,
hostDeviceName: null,
message: null,
// Scan info
username: "",
currentTime: this.getLocalDatetimeString(),
patientID: "",
sampleType: "thin",
scanRunning: false,
// Scan settings
bayer: false,
grid: [10, 10, 9],
stride_size: [800, 600, 10],
fast_autofocus: true,
autofocus_dz: 2000,
style: "raster",
use_video_port: false
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
currentTimeForm: {
get() {
// Chop the timezone information from the end
return this.currentTime.substr(0, 19);
},
set(val) {
// Get timezone
var dt = new Date();
var tzo = -dt.getTimezoneOffset(),
dif = tzo >= 0 ? "+" : "-",
pad = function(num) {
var norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
};
// Stick to the end of the new timestring from the form
this.currentTime = val + dif + pad(tzo / 60) + ":" + pad(tzo % 60);
}
},
payload: {
get() {
return {
filename: `${this.patientID}_${this.sampleType}`,
temporary: false,
bayer: this.bayer,
grid: this.grid,
stride_size: this.stride_size,
fast_autofocus: this.fast_autofocus,
autofocus_dz: this.autofocus_dz,
use_video_port: this.use_video_port,
annotations: {
patientID: this.patientID,
username: this.username,
clientDatetime: this.currentTime
},
tags: ["slidescan"]
};
}
}
},
mounted: function() {
this.updateScanUri();
},
methods: {
updateScanUri: function() {
axios
.get(this.pluginsUri) // Get a list of plugins
.then(response => {
var plugins = response.data;
var foundExtension = plugins.find(
e => e.title === "org.openflexure.scan"
);
// if ScanPlugin is enabled
if (foundExtension) {
// Get plugin action link
this.scanUri = foundExtension.links.tile.href;
}
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanError: function(error) {
this.scanRunning = false;
this.modalError(error);
},
decrement: function() {
if (this.stepValue > 0) {
this.stepValue = this.stepValue - 1;
}
},
increment: function() {
// Validate sections
if (this.stepValue == 0) {
if (!this.username) {
this.message = "Please enter your name";
return false;
}
}
if (this.stepValue == 1) {
if (!this.patientID) {
this.message = "Please enter a patient ID";
return false;
}
}
// Upper bound on section number
if (this.stepValue < 3) {
this.stepValue = this.stepValue + 1;
this.message = "";
return true;
}
},
restart: function() {
this.stepValue = 0;
this.patientID = "";
this.currentTime = this.getLocalDatetimeString();
},
getLocalDatetimeString: function() {
var dt = new Date();
var tzo = -dt.getTimezoneOffset(),
dif = tzo >= 0 ? "+" : "-",
pad = function(num) {
var norm = Math.floor(Math.abs(num));
return (norm < 10 ? "0" : "") + norm;
};
return (
dt.getFullYear() +
"-" +
pad(dt.getMonth() + 1) +
"-" +
pad(dt.getDate()) +
"T" +
pad(dt.getHours()) +
":" +
pad(dt.getMinutes()) +
":" +
pad(dt.getSeconds()) +
dif +
pad(tzo / 60) +
":" +
pad(tzo % 60)
);
}
}
};
</script>
<style>
.grid-container {
display: grid;
grid-template-columns: auto auto;
grid-column-gap: 10px;
}
.warning {
color: #c11;
font-weight: bold;
text-align: center;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component">
<paneSlideScan />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneSlideScan from "./slideScanComponents/paneSlideScan";
import streamDisplay from "./streamContent.vue";
export default {
name: "SlideScanContent",
components: {
paneSlideScan,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,317 @@
<template>
<div
id="stream-display"
ref="streamDisplay"
v-observe-visibility="visibilityChanged"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
>
<img
v-if="isVisible"
ref="click-frame"
class="uk-align-center uk-margin-remove-bottom"
:hidden="!streamEnabled"
:src="streamImgUri"
alt="Stream"
@dblclick="clickMonitor"
/>
<div v-if="!streamEnabled" class="uk-height-1-1">
<div v-if="$store.state.waiting" class="uk-position-center">
<div uk-spinner="ratio: 4.5"></div>
</div>
<div
v-else-if="$store.state.disableStream"
class="uk-position-center position-relative text-center"
>
Stream preview disabled
</div>
<div v-else class="uk-position-center position-relative text-center">
No active connection
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "StreamDisplay",
data: function() {
return {
isVisible: false,
displaySize: [0, 0],
displayPosition: [0, 0],
resizeTimeoutId: setTimeout(this.doneResizing, 500)
};
},
computed: {
streamEnabled: function() {
return this.$store.getters.ready && !this.$store.state.disableStream;
},
thisStreamOpen: function() {
// Only a single MJPEG connection should be open at a time
return !(this.displaySize[0] == 0) && !(this.displaySize[1] == 0);
},
streamImgUri: function() {
return `${this.$store.getters.baseUri}/api/v2/streams/mjpeg`;
},
startPreviewUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/preview/start`;
},
stopPreviewUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions/camera/preview/stop`;
},
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
autoGpuPreview: function() {
return this.$store.state.autoGpuPreview;
}
},
watch: {
autoGpuPreview: function(newValue) {
// When the GPU preview setting in the store changes, update the server
this.safePreviewRequest(newValue);
}
},
mounted() {
// A global signal listener to change the GPU preview state
this.$root.$on("globalTogglePreview", state => {
this.previewRequest(state);
});
// A global signal listener to flash the stream element
this.$root.$on("globalFlashStream", () => {
this.flashStream();
});
// 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);
},
created: function() {
// Send a request to start/stop GPU preview based on global setting
this.safePreviewRequest(this.autoGpuPreview);
},
beforeDestroy: 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");
// Disconnect the size observer
this.sizeObserver.disconnect();
// Remove from the array of active streams
this.$store.commit("removeStream", this._uid);
},
methods: {
visibilityChanged(isVisible) {
this.isVisible = isVisible;
},
flashStream: function() {
// Run an animation that flashes the stream (for capture feedback)
let element = this.$refs.streamDisplay;
element.classList.remove("uk-animation-fade");
element.offsetHeight; /* trigger reflow */
element.classList.add("uk-animation-fade");
setTimeout(function() {
element.classList.remove("uk-animation-fade");
}, 800);
},
clickMonitor: function(event) {
// Calculate steps from event coordinates
let xCoordinate = event.offsetX;
let yCoordinate = event.offsetY;
// Simply scaling by naturalHeight/offsetHeight may give the wrong answer!
// because we use content-fit: contain in the stylesheet, the img element
// may be larger than the picture. So, we must determine whether the
// width or height is setting the scaling factor, and use a uniform scale
// factor.
let scale = Math.max(
event.target.naturalWidth / event.target.offsetWidth,
event.target.naturalHeight / event.target.offsetHeight
);
let xRelative = (0.5 * event.target.offsetWidth - xCoordinate) * scale;
let yRelative = (0.5 * event.target.offsetHeight - yCoordinate) * scale;
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit(
"globalMoveInImageCoordinatesEvent",
-xRelative,
-yRelative
);
},
handleResize: function() {
// Only fires resize event after no resize in 500ms (prevents resize event spam)
clearTimeout(this.resizeTimeoutId);
this.resizeTimeoutId = setTimeout(this.handleDoneResize, 250);
},
handleDoneResize: function() {
// Recalculate size
this.recalculateSize();
// Handle closed stream
if (this.displaySize[0] == 0 && this.displaySize[1] == 0) {
// If this stream was previously active
if (this.$store.state.activeStreams[this._uid] == true) {
this.$store.commit("removeStream", this._uid);
// If all streams are closed, request the GPU preview close
var a = Object.values(this.$store.state.activeStreams);
let allClosed = a.every(v => v === false);
if (allClosed) {
this.safePreviewRequest(false);
}
}
// If resized to anything other than zero
} else {
this.$store.commit("addStream", this._uid);
if (this.$store.state.autoGpuPreview == true) {
// Start the preview immediately
this.safePreviewRequest(true);
// Send another start preview request after a timeout
/*
The internal logic of this component means that a stop GPU preview
request will only ever be sent if ALL stream components are invisible.
However, when switching tabs, sometimes the previous component will
react to becoming invisible before the new tab has become visible.
This results in a stop-preview request being sent JUST before the new
start preview request is sent. In an ideal network, this is fine, however
due to network jitter, these requests will occasionally be recieved out
of order, causing the preview to start in the new location, and then
quickly stop again.
Preventing this properly will involve some clever server-side logic
probably, however as a pit-stop solution, we always send another
start-preview request after 1 second. This means that either:
1. The initial requests happened in order, in which case this follow-up
request does nothing, or
2. The requests leapfrog eachother, stopping the preview, in which case
the follow-up request restarts the preview in the correct location.
*/
setTimeout(() => this.safePreviewRequest(true), 250);
}
}
},
recalculateSize: function() {
// Calculate stream size
let element = this.$refs.streamDisplay.parentNode;
let bound = element.getBoundingClientRect();
let elementSize = [bound.width, bound.height];
let elementPositionOnWindow = [bound.left, bound.top];
let windowPositionOnDisplay = [window.screenX, window.screenY];
let windowChromeHeight = window.outerHeight - window.innerHeight;
let elementPositionOnDisplay = [
Math.max(0, windowPositionOnDisplay[0] + elementPositionOnWindow[0]),
Math.max(
0,
windowPositionOnDisplay[1] +
elementPositionOnWindow[1] +
windowChromeHeight
)
];
this.displaySize = elementSize;
this.displayPosition = elementPositionOnDisplay;
},
safePreviewRequest: function(state) {
// previewRequest, but only stopping preview if all streams are invisible
// and only starting preview if any stream is visible
var a = Object.values(this.$store.state.activeStreams);
let allClosed = a.every(v => v === false);
// If all streams are closed, don't start GPU preview
if (state === true && allClosed) {
return false;
}
// If not all streams are closed, don't stop GPU preview
if (state === false && !allClosed) {
return false;
}
return this.previewRequest(state);
},
previewRequest: function(state) {
// If requesting starting the stream, but this component is inactive, skip
if (
this.$store.getters.ready == true &&
this.$store.state.autoGpuPreview == true
) {
var requestUri = null;
// Create URI
if (state == true) {
requestUri = this.startPreviewUri;
} else {
requestUri = this.stopPreviewUri;
}
// Generate payload if tracking window position
var payload = {};
if (this.$store.state.trackWindow == true && state == true) {
// Recalculate frame dimensions and position
this.recalculateSize();
// Copy data into payload array
payload = {
window: [
this.displayPosition[0],
this.displayPosition[1],
this.displaySize[0],
this.displaySize[1]
]
};
}
// Send preview request
axios
.post(requestUri, payload)
.then(() => {})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
}
}
};
</script>
<style scoped lang="less">
.stream-display img {
height: 100%;
text-align: center;
object-fit: contain;
}
.stream-display {
width: 100%;
height: 100%;
}
.position-relative {
position: relative !important;
}
.text-center {
text-align: center;
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<!-- Grid managing tab content -->
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import streamDisplay from "./streamContent.vue";
export default {
name: "ViewContent",
components: {
streamDisplay
}
};
</script>