Switched client from submodule to included

This commit is contained in:
Joel Collins 2020-06-25 15:40:51 +01:00
commit 98819403b3
68 changed files with 22018 additions and 5 deletions

View file

@ -0,0 +1,426 @@
<template>
<div
id="app"
class="uk-height-1-1 uk-margin-remove uk-padding-remove"
:class="handleTheme"
>
<loadingContent v-if="!$store.getters.ready" />
<div v-if="$store.getters.ready" id="tour-header"></div>
<appContent v-if="$store.getters.ready" />
<v-tour
v-show="$store.getters.ready"
name="guidedTour"
:steps="tourSteps"
:callbacks="tourCallbacks"
:options="{ highlight: true }"
></v-tour>
</div>
</template>
<script>
// Import components
import appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue";
import axios from "axios";
// Key Codes
const keyCodes = {
pgup: 33,
pgdn: 34,
left: 37,
up: 38,
right: 39,
down: 40,
enter: 13,
esc: 27,
shift: 16,
alt: 18,
t: 84
};
// Export main app
export default {
name: "App",
components: {
appContent,
loadingContent
},
data: function() {
return {
appAvailable: false,
keysDown: {},
systemDark: undefined,
themeObserver: undefined,
tourCallbacks: {
onStop: () => {
this.setLocalStorageObj("completedTour", true);
}
}
};
},
computed: {
isSystemDark: function() {
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
return true;
} else {
return false;
}
},
handleTheme: function() {
var isDark = false;
if (this.$store.state.globalSettings.appTheme == "dark") {
isDark = true;
} else if (this.$store.state.globalSettings.appTheme == "system") {
if (this.systemDark) {
isDark = true;
}
}
return {
"uk-light": isDark,
"uk-background-secondary": isDark
};
},
tourSteps: function() {
return [
{
target: "#tour-header", // We're using document.querySelector() under the hood
header: {
title: "Welcome to the OpenFlexure Microscope"
},
content: `Click Next to learn how to use your microscope`,
params: {
placement: "bottom"
}
},
{
target: "#gallery-tab-icon",
header: {
title: "Capture gallery"
},
content: `View and download your microscope images from the gallery tab`
},
{
target: "#navigate-tab-icon",
header: {
title: "Navigate around your sample"
},
content: `Move your microscope stage and perform autofocus from the navigate tab`
},
{
target: "#capture-tab-icon",
header: {
title: "Capture microscope images"
},
content: `Take images and simple tile scans from the capture tab`
},
{
target: "#settings-tab-icon",
header: {
title: "Change settings"
},
content: `Change app and microscope settings, including microscope calibration, from the settings tab`
},
{
target: "#extension-tab-divider",
header: {
title: "Microscope extensions"
},
content: `Extensions installed on your microscope will appear below this line`
}
];
}
},
mounted() {
// Query CSS dark theme preference
var mql = window.matchMedia("(prefers-color-scheme: dark)");
// Check for system dark theme when mounted
if (mql.matches) {
this.systemDark = true;
}
// Create a theme observer to watch for changes
this.themeObserver = mql.addListener(e => {
if (e.matches) {
this.systemDark = true;
} else {
this.systemDark = false;
}
});
// Check connection to API
this.checkConnection();
// Handle guided tour
// If the user has already completed or skipped the guided tour
// TODO: Only run this if connected to the API
var completedTour = this.getLocalStorageObj("completedTour") || false;
if (!completedTour) {
this.$tours["guidedTour"].start();
}
},
created: function() {
window.addEventListener("beforeunload", this.handleExit);
// Key events
window.addEventListener("keydown", this.keyDownMonitor);
window.addEventListener("keyup", this.keyUpMonitor);
window.addEventListener("wheel", this.wheelMonitor);
// Watch for origin changes
this.unwatchOriginFunction = this.$store.watch(
(state, getters) => {
return getters.uriV2;
},
uriV2 => {
this.checkConnection();
console.log(uriV2);
}
);
},
beforeDestroy: function() {
// Disconnect the theme observer
if (this.themeObserver) {
this.themeObserver.disconnect();
}
// Remove key listeners
window.removeEventListener("keydown", this.keyDownMonitor);
window.removeEventListener("keyup", this.keyUpMonitor);
window.removeEventListener("wheel", this.wheelMonitor);
// Remove origin watcher
this.unwatchOriginFunction();
},
methods: {
checkConnection: function() {
var uriV2 = this.$store.getters.uriV2;
this.$store.commit("changeWaiting", true);
axios
.get(uriV2)
.then(() => {
this.$store.commit("setConnected");
this.$store.commit("setErrorMessage", null);
})
.catch(error => {
this.$store.commit("setErrorMessage", error);
})
.finally(() => {
this.$store.commit("changeWaiting", false);
});
},
handleExit: function() {
console.log("Triggered beforeunload");
this.$root.$emit("globalTogglePreview", false);
},
// Handle global mouse wheel events to be associated with navigation
wheelMonitor: function(event) {
// Only capture scroll if the event target's parent contains the "scrollTarget" class
if (
event.target.parentNode.classList.contains("scrollTarget") ||
event.target.classList.contains("scrollTarget")
) {
var z_rel = event.deltaY / 100;
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit("globalMoveStepEvent", 0, 0, z_rel, false);
}
},
// Handle global key press events to be associated with navigation
keyDownMonitor: function(event) {
this.keysDown[event.keyCode] = true; //Add key to array
// Convert keyCode dict into a list of key codes
var keyCodeList = Object.keys(keyCodes).map(function(key) {
return keyCodes[key];
});
if (
// If not inside an element we want to ignore
!(event.target instanceof HTMLInputElement) &&
!event.target.classList.contains("lightbox-link") &&
// If it's a recognised key
keyCodeList.includes(event.keyCode)
) {
this.navigateKeyHandler(keyCodes);
this.captureKeyHandler(keyCodes);
this.letterKeyHandler(keyCodes);
}
},
keyUpMonitor: function(event) {
delete this.keysDown[event.keyCode]; //Remove key from array
},
navigateKeyHandler: function(keyCodes) {
const moveKeys = [
keyCodes.left,
keyCodes.right,
keyCodes.up,
keyCodes.down,
keyCodes.pgup,
keyCodes.pgdn
];
if (
moveKeys.some(r => Object.keys(this.keysDown).includes(r.toString()))
) {
// Calculate movement array
var x_rel = 0;
var y_rel = 0;
var z_rel = 0;
if (keyCodes.left in this.keysDown) {
x_rel = x_rel + 1;
}
if (keyCodes.right in this.keysDown) {
x_rel = x_rel - 1;
}
if (keyCodes.up in this.keysDown) {
y_rel = y_rel + 1;
}
if (keyCodes.down in this.keysDown) {
y_rel = y_rel - 1;
}
if (keyCodes.pgup in this.keysDown) {
z_rel = z_rel - 1;
}
if (keyCodes.pgdn in this.keysDown) {
z_rel = z_rel + 1;
}
// Make a position request
// Emit a signal to move, acted on by panelNavigate.vue
this.$root.$emit("globalMoveStepEvent", x_rel, y_rel, z_rel);
}
},
captureKeyHandler: function(keyCodes) {
if (keyCodes.shift in this.keysDown && keyCodes.enter in this.keysDown) {
console.log("Capturing");
this.$root.$emit("globalCaptureEvent");
}
},
letterKeyHandler: function(keyCodes) {
if (keyCodes.alt in this.keysDown && keyCodes.t in this.keysDown) {
this.$tours["guidedTour"].start();
}
}
}
};
</script>
<style lang="less">
// Basic UIkit CSS
@import "../node_modules/uikit/src/less/uikit.less";
// Custom UIkit CSS modifications
@import "./assets/less/theme.less";
// We override the custom-electron-titlebar z-index
// UIKit lightbox must be able to draw over the titlebar
// as it currently always spawns at the root of the DOM
.titlebar,
.titlebar > * {
z-index: 1000 !important;
}
#app {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
height: 100%;
}
body,
html {
height: 100%;
overflow: hidden;
}
.uk-disabled {
pointer-events: none;
opacity: 0.4;
}
.control-component {
overflow-y: auto;
overflow-x: hidden;
width: 300px;
height: 100%;
padding: 0;
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);
}
.view-component {
overflow-y: auto;
overflow-x: hidden;
height: 100%;
padding: 0;
}
.section-content {
padding: 0;
height: 100%;
}
// Style tour
.v-tour__target--highlighted {
box-shadow: 0px 40px 200px 30px rgba(0, 0, 0, 0.5),
0px 0px 0px 4px rgba(128, 128, 128, 0.5) !important;
border-radius: 5px;
opacity: 100% !important;
pointer-events: none !important;
}
.v-step {
background: @global-primary-background !important;
}
.v-step__header {
background-color: darken(@global-primary-background, 7%) !important;
}
.v-step__button {
font-size: 0.9rem !important;
}
// Change step arrow colour
// This is awful and hacky and makes me sad, but needs must
.v-step .v-step__arrow {
border-color: darken(@global-primary-background, 7%) !important;
&--dark {
border-color: darken(@global-primary-background, 7%) !important;
}
}
.v-step[x-placement^="top"] .v-step__arrow {
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-step[x-placement^="bottom"] .v-step__arrow {
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
}
.v-step[x-placement^="right"] .v-step__arrow {
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-step[x-placement^="left"] .v-step__arrow {
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
</style>

View file

@ -0,0 +1,18 @@
// Theming specific to the electron app
.menubar-menu-container {
box-shadow: 0.5px 1px 4px 0px rgba(0, 0, 0, 0.3) !important;
}
.menubar-menu-container>* {
box-shadow: none !important;
}
.menubar-menu-container .action-menu-item,
.menubar-menu-container .action-label {
cursor: default
}
.menubar-menu-container a:hover {
text-decoration: none;
}

View file

@ -0,0 +1,67 @@
/*
* UIkit Theme for Highlight.js
*/
.hljs {
display: block;
color: #333;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: @global-danger-background;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #1f34aa;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}

View file

@ -0,0 +1,362 @@
// UIkit Core
@import "../../../node_modules/uikit/src/less/uikit.theme.less";
// Highlight.js
@import "./highlight.less";
// Electron app theming
@import "./app.less";
// Custom OpenFlexure theming
//
// Colors
//
@global-color: #666;
@global-emphasis-color: #333;
@global-muted-color: #666;
@global-primary-background: #C32280;
@inverse-primary-muted-color: lighten(@global-primary-background, 15%);
@inverse-global-color: fade(@global-inverse-color, 80%);
@global-border: #d5d5d5;
// UIkit
// ========================================================================
@global-font-family: ProximaNova, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@global-font-size: 14px;
@global-xxlarge-font-size: 38px;
@global-xlarge-font-size: 30px;
@global-large-font-size: 24px;
@global-medium-font-size: 20px;
@global-small-font-size: 14px;
@global-emphasis-color: #222;
//
// Base
//
@base-code-font-family: 'Roboto Mono', monospace;
@base-code-font-size: 12px;
@base-heading-font-weight: 300;
@base-pre-font-size: 12px;
@base-pre-padding: 25px;
@base-pre-background: @global-muted-background;
@base-pre-border-width: 0;
@base-pre-border-radius: 0;
.hook-base-body() {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
//
// Container
//
@container-max-width: 1380px;
@container-small-max-width: 650px;
//
// Navbar
//
@inverse-navbar-nav-item-color: @inverse-global-color;
@inverse-navbar-nav-item-hover-color: @inverse-global-emphasis-color;
//
// Nav
//
@nav-header-font-size: 12px;
//
// Subnav
//
@inverse-subnav-item-color: @inverse-global-color;
@inverse-subnav-item-hover-color: @inverse-global-emphasis-color;
//
// Tab
//
@tab-item-padding-horizontal: 10px;
@tab-item-padding-vertical: 9px;
@tab-item-border-width: 2px;
@tab-item-font-size: 12px;
.hook-tab-item() {
line-height: 20px;
}
//
// Table
//
@table-header-cell-font-size: 12px;
//
// Label
//
@label-font-size: 12px;
//
// Text
//
.hook-text-lead() {
font-weight: 300;
}
.hook-text-large() {
font-weight: 300;
}
//
// Utility
//
@inverse-logo-color: @inverse-global-emphasis-color;
@inverse-logo-hover-color: @inverse-global-emphasis-color;
//
// Off-canvas
//
@offcanvas-bar-background: #fff;
@offcanvas-bar-color-mode: dark;
//
// Inverse
//
@inverse-global-color: fade(@global-inverse-color, 80%);
@inverse-global-muted-color: fade(@global-inverse-color, 60%);
//
// Paper
//
@paper-border-radius: 4px;
/* ========================================================================
Theme
========================================================================== */
@sidebar-left-width: 240px;
@sidebar-left-width-xl: 300px;
@sidebar-right-width: 200px;
@sidebar-right-left: 0px;
@sidebar-right-left-xl: 60px;
/* UIkit modifiers
========================================================================== */
/*
* Navbar
*/
// Line Mode
@navbar-nav-item-line-margin-vertical: 20px;
@navbar-nav-item-line-margin-horizontal: @navbar-nav-item-padding-horizontal;
@navbar-nav-item-line-height: 1px;
@navbar-nav-item-line-background: currentColor;
@navbar-nav-item-line-transition-duration: 0.3s;
.uk-navbar-dropdown.uk-open {
border-radius: @paper-border-radius;
}
/*
* Cards
*/
.uk-card {
border-radius: @paper-border-radius;
border: 1px solid rgba(180, 180, 180, 0.25);
box-shadow: none;
-webkit-box-shabox-shadow: none;
}
.uk-card-media-top img {
border-radius: @paper-border-radius @paper-border-radius 0 0;
}
.uk-card-default .uk-card-footer {
border-top: 1px solid rgba(180, 180, 180, 0.25);
}
.uk-card-primary .uk-card-footer {
border-top: 1px solid rgba(0, 0, 0, 0.075);
}
.uk-card-default {
color: @global-color;
}
.hook-inverse() {
// Override background colour in dark mode
.uk-card-default {
background-color: #2a2a2a !important;
color: @inverse-global-color;
}
// Lighten on hover to show depth in dark mode
.uk-card-default:hover {
transition: background-color @animation-fast-duration ease;
background-color: #333 !important;
}
}
/*
* Modal
*/
.uk-modal-dialog {
border-radius: @paper-border-radius;
box-shadow: 0 14px 25px rgba(0, 0, 0, 0.16);
transition: 0.3s ease !important;
}
.uk-modal-footer {
border-radius: 0 0 @paper-border-radius @paper-border-radius;
}
.uk-modal-header {
border-radius: @paper-border-radius @paper-border-radius 0 0;
}
/*
* Notifications
*/
.uk-notification-message {
border-radius: @paper-border-radius;
box-shadow: 0 14px 25px rgba(0, 0, 0, 0.16);
}
.uk-notification-message-danger {
border: 1px solid @notification-message-danger-color
}
.uk-notification-message-warning {
border: 1px solid @notification-message-warning-color
}
.uk-notification-message-success {
border: 1px solid @notification-message-success-color
}
/*
* Links
*/
.uk-link,
.uk-button-link {
color: @global-muted-color;
}
.uk-link:hover,
.uk-button-link:hover,
a:hover {
color: @global-primary-background;
}
.hook-inverse() {
.uk-link:hover,
.uk-button-link:hover,
a:hover {
color: @inverse-primary-muted-color;
}
}
/*
* Buttons
*/
.uk-button {
border-radius: 2px;
padding: 0 8px;
}
.uk-button-default {
background-color: rgba(180, 180, 180, 0.10);
border-color: @global-border;
}
.uk-button-danger {
background-color: transparent;
color: #f0506e;
border: 1px solid #f0506e;
}
.hook-inverse() {
.uk-button-primary {
background-color: rgba(180, 180, 180, 0.15);
color: @inverse-primary-muted-color;
border-color: @inverse-primary-muted-color;
}
.uk-button-primary:hover {
background-color: @inverse-primary-muted-color;
color: #fff;
}
}
.uk-icon-button,
.uk-icon-button:hover {
text-decoration: none
}
/*
* Accordion
*/
.uk-accordion-title {
display: block;
font-size: @global-font-size;
line-height: 1.4;
color: #333;
font-weight: 500;
}
.uk-accordion-title::before {
content: "";
width: 1.4em;
height: 1.4em;
float: left;
margin: 0 2px 0 0;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3Cpath fill='none' d='M0 24V0h24v24H0z'/%3E%3C/svg%3E");
mask-position: 100% 50%;
background-image: none;
background-color: #333;
}
.uk-open>.uk-accordion-title::before {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3Cpath d='M0 0h24v24H0z' fill='none'/%3E%3C/svg%3E");
mask-position: 100% 50%;
background-image: none;
}
.uk-accordion-content {
margin-top: 10px;
}
.hook-inverse() {
.uk-accordion-title::before {
background-color: #fff;
}
}

View file

@ -0,0 +1,317 @@
<template>
<div
id="app-content"
class="uk-margin-remove uk-padding-remove uk-height-1-1"
uk-grid
>
<!-- Initialisation modals -->
<calibrationModal
ref="calibrationModal"
:available-plugins="plugins"
@onClose="enterApp()"
></calibrationModal>
<!-- Vertical tab bar -->
<div
id="switcher-left"
class="uk-flex uk-flex-column uk-padding-remove uk-width-auto uk-height-1-1 uk-text-center"
>
<tabIcon
id="view-tab-icon"
tab-i-d="view"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">visibility</i>
</tabIcon>
<tabIcon
id="gallery-tab-icon"
tab-i-d="gallery"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">photo_library</i>
</tabIcon>
<hr />
<tabIcon
id="navigate-tab-icon"
tab-i-d="navigate"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">gamepad</i>
</tabIcon>
<tabIcon
id="capture-tab-icon"
tab-i-d="capture"
:require-connection="true"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">camera_alt</i>
</tabIcon>
<tabIcon
id="settings-tab-icon"
tab-i-d="settings"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">settings</i>
</tabIcon>
<hr id="extension-tab-divider" />
<tabIcon
v-for="plugin in pluginsGuiList"
:key="plugin.id"
:tab-i-d="plugin.id"
:title="plugin.title"
:require-connection="plugin.requiresConnection"
:current-tab="currentTab"
:click-callback="updatePlugins"
@set-tab="setTab"
>
<i class="material-icons">{{ plugin.icon || "extension" }}</i>
</tabIcon>
<tabIcon
id="about-tab-icon"
class="uk-margin-auto-top"
tab-i-d="about"
:require-connection="false"
:current-tab="currentTab"
@set-tab="setTab"
>
<i class="material-icons">info</i>
</tabIcon>
</div>
<!-- Corresponding vertical tab content -->
<div
id="container-left"
class="uk-padding-remove uk-height-1-1 uk-width-expand"
>
<tabContent
tab-i-d="view"
:require-connection="true"
:current-tab="currentTab"
>
<viewContent />
</tabContent>
<tabContent
tab-i-d="gallery"
:require-connection="false"
:current-tab="currentTab"
>
<galleryContent />
</tabContent>
<tabContent
tab-i-d="navigate"
:require-connection="true"
:current-tab="currentTab"
>
<navigateContent />
</tabContent>
<tabContent
tab-i-d="capture"
:require-connection="true"
:current-tab="currentTab"
>
<captureContent />
</tabContent>
<tabContent
tab-i-d="settings"
:require-connection="false"
:current-tab="currentTab"
>
<settingsContent class="section-content" />
</tabContent>
<tabContent
v-for="plugin in pluginsGuiList"
:key="plugin.id"
:tab-i-d="plugin.id"
:require-connection="plugin.requiresConnection"
:current-tab="currentTab"
>
<extensionContent
:forms="plugin.forms"
:frame="plugin.frame"
:web-component="plugin.wc"
:view-panel="plugin.viewPanel"
@reloadForms="updatePlugins()"
/>
</tabContent>
<tabContent
tab-i-d="about"
:require-connection="false"
:current-tab="currentTab"
>
<aboutContent class="section-content" />
</tabContent>
</div>
</div>
</template>
<script>
import axios from "axios";
// Import generic components
import tabIcon from "./genericComponents/tabIcon";
import tabContent from "./genericComponents/tabContent";
// Import new content components
import navigateContent from "./tabContentComponents/navigateContent.vue";
import captureContent from "./tabContentComponents/captureContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue";
import settingsContent from "./tabContentComponents/settingsContent.vue";
import galleryContent from "./tabContentComponents/galleryContent.vue";
import extensionContent from "./tabContentComponents/extensionContent.vue";
import aboutContent from "./tabContentComponents/aboutContent.vue";
// Import modal components for device initialisation
import calibrationModal from "./modalComponents/calibrationModal.vue";
// Export main app
export default {
name: "AppContent",
components: {
tabIcon,
tabContent,
navigateContent,
captureContent,
viewContent,
settingsContent,
galleryContent,
extensionContent,
calibrationModal,
aboutContent
},
data: function() {
return {
plugins: [],
currentTab: "view",
unwatchStoreFunction: null
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
pluginsGuiList: function() {
// List of plugin GUIs, obtained from this.plugins values
console.log("Recalculating plugins");
var pluginGuis = [];
for (let plugin of Object.values(this.plugins)) {
if (plugin.meta.gui) {
pluginGuis.push(plugin.meta.gui);
}
}
return pluginGuis;
}
},
created: function() {
if (this.$store.getters.ready) {
// Detect local connection
if (
["localhost", "0.0.0.0", "127.0.0.1", "[::1]"].includes(
window.location.hostname
)
) {
this.$store.commit("changeSetting", ["disableStream", true]);
this.$store.commit("changeSetting", ["autoGpuPreview", true]);
this.$store.commit("changeSetting", ["trackWindow", true]);
}
// Update plugins
this.updatePlugins().then(() => {
// Start initialisation modals
this.startModals();
});
}
},
mounted() {
// A global signal listener to switch tab
this.$root.$on("globalSwitchTab", tabID => {
this.currentTab = tabID;
});
},
beforeDestroy() {
// Then we call that function here to unwatch
if (this.unwatchStoreFunction) {
this.unwatchStoreFunction();
this.unwatchStoreFunction = null;
}
},
methods: {
updatePlugins: function() {
console.log("Updating plugin forms");
return axios
.get(this.pluginsUri)
.then(response => {
console.log(response);
this.plugins = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
setTab: function(event, tab) {
if (!(this.currentTab == tab)) {
this.currentTab = tab;
}
},
startModals: function() {
this.$refs["calibrationModal"].show();
},
enterApp: function() {
// Stuff to do once connected and all init modals are finished
console.log("Entering main application");
this.currentTab = "view";
}
}
};
</script>
<style scoped lang="less">
#component-left {
width: 100%;
height: 100%;
}
#container-left {
overflow: hidden;
background-color: rgba(180, 180, 180, 0.025);
width: 100%;
height: 100%;
}
#switcher-left {
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
#switcher-left a {
padding: 10px 8px;
}
#switcher-left {
width: 75px;
background-color: rgba(180, 180, 180, 0.1);
padding-top: 2px !important;
}
</style>

View file

@ -0,0 +1,35 @@
<template>
<div>
<form class="uk-form-stacked" @submit.prevent="overrideAPIHost">
<label class="uk-form-label">Override API origin</label>
<input v-model="currentOrigin" class="uk-input" type="text" />
</form>
</div>
</template>
<script>
// Export main app
export default {
name: "DevTools",
components: {},
data: function() {
return {
currentOrigin: this.$store.state.origin
};
},
methods: {
overrideAPIHost: function() {
this.$store.commit("changeOrigin", this.currentOrigin);
}
}
};
</script>
<style scoped lang="less">
.error-icon {
font-size: 120px;
}
</style>

View file

@ -0,0 +1,467 @@
<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">
<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="inputPositionZx"
/>
</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"
/>
</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"
/>
</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="inputPositionZx"
/>
</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>
</select>
</div>
</div>
</div>
</li>
</ul>
<div v-if="scanCapture" class="uk-margin uk-margin-remove-top ">
<taskSubmitter
:submit-url="scanUri"
:submit-data="scanPayload"
:submit-label="'Start Scan'"
:button-primary="true"
@submit="onScanSubmit"
@response="onScanResponse"
@error="onScanError"
>
</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";
// Export main app
export default {
name: "PaneCapture",
components: {
tagList,
keyvalList,
taskSubmitter
},
data: function() {
return {
filename: "",
temporary: false,
fullResolution: false,
storeBayer: false,
resizeCapture: false,
captureNotes: "",
scanCapture: false,
scanDeltaZ: "Fast",
scanStyle: "Raster",
scanStepSize: {
x: 0,
y: 0,
z: 0
},
scanSteps: {
x: 3,
y: 3,
z: 5
},
resizeDims: [640, 480],
tags: [],
annotations: {
Client: `${process.env.PACKAGE.name}.${process.env.PACKAGE.version}`
},
scanUri: 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`;
},
settingsFovUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings/fov`;
},
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;
}
console.log(payload);
return payload;
},
scanPayload: function() {
var payload = this.basePayload;
// Scan params
payload.grid = [this.scanSteps.x, this.scanSteps.y, this.scanSteps.z];
payload.stride_size = [
this.scanStepSize.x,
this.scanStepSize.y,
this.scanStepSize.z
];
payload.style = this.scanStyle.toLowerCase();
// Convert AF selector to dz
var afDeltas = {
Off: 0,
Coarse: 100,
Medium: 30,
Fine: 10,
Fast: 2000
};
payload.autofocus_dz = afDeltas[this.scanDeltaZ];
payload.fast_autofocus = this.scanDeltaZ == "Fast";
return payload;
}
},
mounted() {
this.updateScanUri();
this.updateScanStepSize();
// 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
});
},
updateScanStepSize: function() {
axios
.get(this.settingsFovUri) // Get the microscope FOV
.then(response => {
this.scanStepSize = {
x: parseInt(0.5 * response.data[0]),
y: parseInt(0.5 * response.data[1]),
z: 50
};
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onScanSubmit: function() {},
onScanResponse: function(responseData) {
console.log("Scan finished with response data: ", responseData);
this.modalNotify("Finished scan.");
},
onScanError: function(error) {
this.modalError(error);
}
}
};
</script>
<style lang="less">
.deletable-label {
cursor: pointer;
}
.deletable-label:hover {
background-color: #f0506e;
}
</style>

View file

@ -0,0 +1,355 @@
<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">
<div class="uk-child-width-1-2" uk-grid>
<div>
<label class="uk-form-label" for="form-stacked-text"
>x-y step size</label
>
<div class="uk-form-controls">
<input
v-model="stepXy"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputStepXy"
/>
</div>
</div>
<div>
<label class="uk-form-label" for="form-stacked-text"
>z step size</label
>
<div class="uk-form-controls">
<input
v-model="stepZz"
class="uk-input uk-form-width-medium uk-form-small"
type="number"
name="inputStepZz"
/>
</div>
</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'"
@taskStarted="isAutofocusing = 1"
@finished="isAutofocusing = 0"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 2">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-60, -30, 0, 30, 60] }"
:submit-label="'Medium'"
@taskStarted="isAutofocusing = 2"
@finished="isAutofocusing = 0"
></taskSubmitter>
</div>
<div v-show="!isAutofocusing || isAutofocusing == 3">
<taskSubmitter
v-if="normalAutofocusUri"
:submit-url="normalAutofocusUri"
:submit-data="{ dz: [-20, -10, 0, 10, 20] }"
:submit-label="'Fine'"
@taskStarted="isAutofocusing = 3"
@finished="isAutofocusing = 0"
></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,
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`;
}
},
mounted() {
// 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);
});
this.$root.$on("globalMoveStepEvent", (x_steps, y_steps, z_steps) => {
this.moveRequest(
x_steps * this.stepXy,
y_steps * this.stepXy,
z_steps * this.stepZz,
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");
},
methods: {
handleSubmit: function() {
this.moveRequest(
this.setPosition.x,
this.setPosition.y,
this.setPosition.z,
true
);
},
moveRequest: function(x, y, z, absolute) {
console.log(`Sending move request of ${x}, ${y}, ${z}`);
// 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) {
console.log(`Sending move request in image coordinates: ${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,70 @@
<template>
<div>
<label>{{ label }}</label>
<div class="uk-form-controls">
<div v-for="option in options" :key="option">
<label>
<input
class="uk-checkbox"
type="checkbox"
:value="option"
:checked="value && value.includes(option)"
v-bind="$attrs"
@change="updateValue($event.target)"
/>
{{ option }}
</label>
</div>
</div>
</div>
</template>
<script>
export default {
name: "CheckList",
props: {
value: {
type: Array,
required: false,
default: function() {
return [];
}
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
},
methods: {
updateValue(target) {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
if (target.checked) {
if (!newSelected.includes(target.value)) {
newSelected.push(target.value);
}
} else {
if (newSelected.includes(target.value)) {
newSelected = newSelected.filter(function(value) {
return value != target.value;
});
}
}
this.$emit("input", newSelected);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,29 @@
<template>
<div>
<p v-html="content"></p>
</div>
</template>
<script>
export default {
name: "HtmlBlock",
props: {
label: {
type: String,
required: false,
default: ""
},
name: {
type: String,
required: true
},
content: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,124 @@
<template>
<div>
<form @submit.prevent="handleMetadataSubmit">
<div class="uk-margin-remove uk-flex uk-flex-middle">
<div
class="uk-margin-remove-top uk-padding-remove uk-grid-small uk-width-expand"
uk-grid
>
<div class="uk-margin-remove uk-width-1-2">
<input
ref="textboxKey"
v-model="newMetadata.key"
class="uk-input uk-form-width-small uk-form-small"
type="text"
name="flavor"
placeholder="Key"
/>
</div>
<div class="uk-margin-remove uk-width-1-2">
<input
v-model="newMetadata.value"
class="uk-input uk-form-width-small uk-form-small"
type="text"
name="flavor"
placeholder="Value"
@keyup.enter="handleMetadataSubmit()"
/>
</div>
</div>
<a
href="#"
class="uk-icon uk-margin-left"
@click="handleMetadataSubmit()"
><i class="material-icons">add_circle</i></a
>
</div>
</form>
<div
v-for="(val, key) in value"
:key="key"
class="uk-width-1-1 uk-margin-small uk-margin-remove-left uk-margin-remove-right uk-flex uk-flex-middle"
>
<div class="uk-margin-remove-top uk-padding-remove uk-width-expand">
<labelInput
:name="key"
:label="key"
:value="value[key]"
@input="value[key] = $event"
/>
</div>
<a href="#" class="uk-icon uk-width-auto" @click="delMetadataKey(key)"
><i class="material-icons">delete</i></a
>
</div>
</div>
</template>
<script>
import labelInput from "../fieldComponents/labelInput";
export default {
name: "KeyvalList",
components: {
labelInput
},
props: {
value: {
type: Object,
required: true
}
},
data: function() {
return {
newMetadata: {
key: "",
value: ""
}
};
},
methods: {
handleMetadataSubmit: function() {
var newSelected = {};
if (this.value != null) {
Object.assign(newSelected, this.value);
}
newSelected[this.newMetadata.key] = this.newMetadata.value;
this.newMetadata.key = "";
this.newMetadata.value = "";
this.$emit("input", newSelected);
// Move focus back to key textbox
this.$refs.textboxKey.focus();
},
delMetadataKey: function(key) {
var newSelected = {};
if (this.value != null) {
Object.assign(newSelected, this.value);
}
this.$delete(newSelected, key);
this.$emit("input", newSelected);
},
modifyValue: function(e, v) {
console.log(e);
console.log(v);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,65 @@
<template>
<div>
<label class="uk-form-label uk-text-bold">{{ label }}</label>
<div
uk-tooltip="title: Click to edit value; delay: 250"
@click="setEditing(true)"
>
<div v-show="editing == false">
<label> {{ value }} </label>
</div>
<input
v-show="editing == true"
ref="textinput"
class="uk-input uk-form-small"
type="text"
:name="name"
:value="value"
autofocus
@blur="setEditing(false)"
@keyup.enter="setEditing(false)"
@input="$emit('input', $event.target.value)"
/>
</div>
</div>
</template>
<script>
export default {
name: "LabelInput",
props: {
label: {
type: String,
required: true
},
name: {
type: String,
required: true
},
value: {
type: String,
required: true
}
},
data: function() {
return {
editing: false
};
},
methods: {
setEditing(editing) {
this.editing = editing;
if (editing == true) {
this.$nextTick(() => this.$refs.textinput.focus());
}
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<label class="uk-form-label">{{ label }}</label>
<input
class="uk-input uk-form-small"
type="number"
:name="name"
:value="value"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('input', Number($event.target.value))"
/>
</div>
</template>
<script>
export default {
name: "NumberInput",
props: {
value: {
type: Number,
required: false,
default: 0
},
placeholder: {
type: Number,
required: false,
default: 0
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,49 @@
<template>
<div>
<label>{{ label }}</label>
<div class="uk-form-controls">
<div v-for="option in options" :key="option">
<label
><input
class="uk-radio"
type="radio"
:name="name"
:value="option"
:checked="value == option"
@input="$emit('input', $event.target.value)"
/>
{{ option }}</label
>
</div>
</div>
</div>
</template>
<script>
export default {
name: "RadioList",
props: {
value: {
type: String,
required: false,
default: ""
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,44 @@
<template>
<div>
<label class="uk-form-label">{{ label }}</label>
<select
class="uk-select uk-form-small"
:value="value"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
>
<option v-for="option in options" :key="option">
{{ option }}
</option>
</select>
</div>
</template>
<script>
export default {
name: "SelectList",
props: {
value: {
type: String,
required: false,
default: ""
},
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
label: {
type: String,
required: true
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,79 @@
<template>
<div>
<form @submit.prevent="handleTagSubmit">
<div class="uk-margin-small uk-flex uk-flex-middle">
<div class="uk-margin-remove-top uk-width-expand">
<div class="uk-inline uk-width-1-1">
<span class="uk-form-icon"
><i class="material-icons">label</i></span
>
<input
v-model="newTag"
class="uk-input uk-form-small"
type="text"
name="flavor"
placeholder="Tag"
/>
</div>
</div>
<a href="#" class="uk-icon uk-margin-left" @click="handleTagSubmit()"
><i class="material-icons">add_circle</i></a
>
</div>
</form>
<span
v-for="tag in value"
:key="tag"
class="uk-label uk-margin-small-right deletable-label"
@click="delTag(tag)"
>
{{ tag }}
</span>
</div>
</template>
<script>
export default {
name: "TagList",
props: {
value: {
type: Array,
required: true
}
},
data: function() {
return {
newTag: ""
};
},
methods: {
handleTagSubmit: function() {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
newSelected.push(this.newTag);
this.newTag = "";
this.$emit("input", newSelected);
},
delTag: function(tag) {
var newSelected = this.value != null ? [...this.value] : []; // Clone value array
if (newSelected.includes(tag)) {
newSelected = newSelected.filter(function(value) {
return value != tag;
});
}
this.$emit("input", newSelected);
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,45 @@
<template>
<div>
<label v-if="label" class="uk-form-label">{{ label }}</label>
<input
class="uk-input uk-form-small"
type="text"
:name="name"
:value="value"
:placeholder="placeholder"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
/>
</div>
</template>
<script>
export default {
name: "TextInput",
props: {
placeholder: {
type: String,
required: false,
default: null
},
label: {
type: String,
required: false,
default: null
},
name: {
type: String,
required: true
},
value: {
type: String,
required: false,
default: ""
}
}
};
</script>
<style scoped></style>

View file

@ -0,0 +1,157 @@
<template>
<div
class="progress uk-margin-top uk-margin-horizontal-remove uk-padding-remove"
>
<div class="indeterminate"></div>
</div>
</template>
<script>
export default {
name: "ProgressBar",
props: {},
computed: {
tooltipOptions: function() {
var title = this.id.charAt(0).toUpperCase() + this.id.slice(1);
return `pos: right; title: ${title}; delay: 500`;
},
classObject: function() {
return {
"tabicon-active": this.currentTab == this.id,
"uk-disabled": this.requireConnection && !this.$store.getters.ready
};
}
},
methods: {
setThisTab(event) {
this.$emit("set-tab", event, this.id);
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,40 @@
<template>
<div
v-if="!(requireConnection && !$store.getters.ready)"
:hidden="currentTab != tabID"
class="uk-width-expand uk-height-1-1"
>
<slot></slot>
</div>
</template>
<script>
export default {
name: "TabContent",
props: {
tabID: {
type: String,
required: true
},
currentTab: {
type: String,
required: true
},
requireConnection: Boolean
},
computed: {},
methods: {}
};
</script>
<style lang="less" scoped>
.section-heading {
display: block;
font-size: 12px;
text-transform: uppercase;
line-height: 20px;
cursor: default;
}
</style>

View file

@ -0,0 +1,116 @@
<template>
<a
href="#"
class="uk-link"
:class="classObject"
:uk-tooltip="tooltipOptions"
@click="setThisTab"
>
<slot></slot>
<div v-if="showTitle" class="tabtitle">
{{ computedTitle }}
</div>
</a>
</template>
<script>
export default {
name: "TabIcon",
props: {
tabID: {
type: String,
required: true
},
title: {
type: String,
required: false,
default: undefined
},
showTitle: {
type: Boolean,
required: false,
default: true
},
showTooltip: {
type: Boolean,
required: false,
default: true
},
currentTab: {
type: String,
required: true
},
clickCallback: {
type: Function,
required: false,
default: null
},
requireConnection: Boolean
},
computed: {
computedTitle: function() {
if (this.title !== undefined) {
return this.title;
} else {
// Get the last section of a fully qualified name
var topName = this.tabID.split(".").pop();
// Make first character uppercase, then add the rest of the string
return topName.charAt(0).toUpperCase() + topName.slice(1);
}
},
tooltipOptions: function() {
if (this.showTooltip) {
return `pos: right; title: ${this.computedTitle}; delay: 500`;
} else {
return false;
}
},
classObject: function() {
return {
"tabicon-active": this.currentTab == this.tabID,
"uk-disabled": this.requireConnection && !this.$store.getters.ready
};
}
},
methods: {
setThisTab(event) {
this.$emit("set-tab", event, this.tabID);
if (this.clickCallback) {
this.clickCallback();
}
}
}
};
</script>
<style lang="less" scoped>
// Custom UIkit CSS modifications
@import "../../assets/less/theme.less";
.tabicon-active {
color: @global-primary-background !important;
}
.hook-inverse() {
.tabicon-active {
color: @inverse-primary-muted-color !important;
}
}
.tabtitle {
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 85%;
}
a:hover,
.uk-link:hover {
text-decoration: none !important;
}
</style>

View file

@ -0,0 +1,209 @@
<template>
<div>
<div
class="progress uk-margin-top uk-margin-horizontal-remove uk-padding-remove"
>
<div
v-if="progress"
class="determinate"
:style="barWidthFromProgress"
></div>
<div v-else class="indeterminate"></div>
</div>
<button
type="button"
class="uk-button uk-button-danger uk-form-small uk-float-right uk-width-1-1"
@click="terminateTask()"
>
Terminate
</button>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "TaskProgress",
props: {
taskId: {
type: String,
required: true
},
pollInterval: {
type: Number,
required: false,
default: 500
}
},
data: function() {
return {
polling: null,
progress: null
};
},
computed: {
barWidthFromProgress: function() {
var progress = this.progress <= 100 ? this.progress : 100;
var styleString = `width: ${progress}%`;
return styleString;
}
},
created() {
this.polling = setInterval(() => {
this.pollProgress();
}, this.pollInterval);
},
beforeDestroy() {
clearInterval(this.polling);
},
methods: {
pollProgress: function() {
console.log("Starting progress polling");
axios
.get(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("PROGRESS RESPONSE: ", response.data.progress);
this.progress = response.data.progress;
});
},
terminateTask: function() {
axios
.delete(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("TERMINATION RESPONSE: ", response.data);
});
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,348 @@
<template>
<div class="uk-margin-remove uk-padding-remove">
<div v-if="taskRunning" ref="isPollingElement">
<div class="progress uk-margin-small">
<div
v-if="progress"
class="determinate"
:style="barWidthFromProgress"
></div>
<div v-else class="indeterminate"></div>
</div>
<button
v-if="canTerminate"
type="button"
class="uk-button uk-button-danger uk-margin-remove uk-float-right uk-width-1-1"
@click="terminateTask()"
>
Terminate
</button>
</div>
<div>
<button
type="button"
:hidden="taskRunning"
class="uk-button uk-margin-remove uk-width-1-1"
:class="[buttonPrimary ? 'uk-button-primary' : 'uk-button-default']"
@click="bootstrapTask()"
>
{{ submitLabel }}
</button>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "TaskSubmitter",
props: {
submitUrl: {
type: String,
required: true
},
submitData: {
type: [Object, Array],
required: false,
default: () => ({})
},
pollInterval: {
type: Number,
required: false,
default: 0.5
},
submitLabel: {
type: String,
required: false,
default: "Submit"
},
canTerminate: {
type: Boolean,
required: false,
default: true
},
requiresConfirmation: {
type: Boolean,
required: false,
default: false
},
confirmationMessage: {
type: String,
required: false,
default: "Start task?"
},
buttonPrimary: {
type: Boolean,
required: false,
default: false
}
},
data: function() {
return {
taskId: null,
polling: null,
progress: null,
taskRunning: false
};
},
computed: {
barWidthFromProgress: function() {
var progress = this.progress <= 100 ? this.progress : 100;
var styleString = `width: ${progress}%`;
return styleString;
}
},
created() {},
beforeDestroy() {},
methods: {
bootstrapTask: function() {
if (this.requiresConfirmation) {
this.modalConfirm(this.confirmationMessage).then(
() => {
this.startTask();
},
() => {}
);
} else {
this.startTask();
}
},
startTask: function() {
console.log("Task start clicked");
this.$emit("submit", this.submitData);
// Send a request to start a task
console.log("Submitting to ", this.submitUrl);
axios
.post(this.submitUrl, this.submitData)
// Get the returned Task ID
.then(response => {
// Fetch the task ID
console.log("Task ID: " + response.data.id);
this.taskId = response.data.id;
// Start the store polling TaskId for success
this.taskRunning = true;
this.$emit("taskStarted", this.taskId);
// Return the poll-task promise (starts polling the task)
return this.pollTask(this.taskId, this.pollInterval);
})
.then(response => {
// Do something with the final response
console.log("Emitting onResponse: ", response);
this.$emit("response", response);
this.$emit("finished");
})
.catch(error => {
if (!error) {
error = Error("Unknown error");
}
console.log("Emitting onError: ", error);
this.$emit("error", error);
this.$emit("finished");
})
.finally(() => {
console.log("Cleaning up after task.");
// Reset taskRunning and taskId
this.taskRunning = false;
this.taskId = null;
// Update the form data if we're self-updating
if (this.selfUpdate) {
this.getFormData();
}
});
},
pollTask: function(taskId, interval) {
interval = interval * 1000 || 500;
var checkCondition = (resolve, reject) => {
// If the condition is met, we're done!
axios
.get(`${this.$store.getters.uriV2}/tasks/${taskId}`)
.then(response => {
console.log(response.data.status);
var result = response.data.status;
// If the task ends with success
if (result == "success") {
resolve(response.data);
}
// If task ends with an error
else if (result == "error") {
// Pass the error string back with reject
reject(new Error(response.data.return));
}
// If task ends with termination
else if (result == "terminated") {
// Pass a generic termination error back with reject
reject(new Error("Task terminated"));
}
// If task ends in any other way
else if (result != "running") {
// Pass status string as error
reject(new Error(result));
}
// Didn't match and too much time, reject!
else {
// Since the task is still running, we can update the progress bar
this.progress = response.data.progress;
// Check again after timeout
setTimeout(checkCondition, interval, resolve, reject);
}
});
};
return new Promise(checkCondition);
},
pollProgress: function() {
console.log("Starting progress polling");
axios
.get(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("PROGRESS RESPONSE: ", response.data.progress);
this.progress = response.data.progress;
});
},
terminateTask: function() {
axios
.delete(`${this.$store.getters.uriV2}/tasks/${this.taskId}`)
.then(response => {
console.log("TERMINATION RESPONSE: ", response.data);
});
}
}
};
</script>
<style lang="less" scoped>
@import "../../assets/less/theme.less";
.progress {
position: relative;
height: 5px;
display: block;
width: 100%;
background-color: rgba(180, 180, 180, 0.15);
border-radius: 2px;
background-clip: padding-box;
margin: 0.5rem 0 1rem 0;
overflow: hidden;
}
.progress .determinate {
position: absolute;
background-color: inherit;
top: 0;
bottom: 0;
transition: width 0.3s linear;
}
.progress .indeterminate,
.progress .determinate {
background-color: @global-primary-background;
}
.hook-inverse() {
.progress .indeterminate,
.progress .determinate {
background-color: @inverse-primary-muted-color;
}
}
.progress .indeterminate:before {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395)
infinite;
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
content: "";
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
-webkit-animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1)
infinite;
-webkit-animation-delay: 1.15s;
animation-delay: 1.15s;
}
@-webkit-keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@-webkit-keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="uk-flex uk-flex-column uk-flex-center uk-height-1-1">
<div
v-if="$store.state.waiting"
class="uk-align-center"
uk-spinner="ratio: 3"
></div>
<div v-if="$store.state.waiting" class="uk-align-center">Loading...</div>
<i class="material-icons uk-align-center error-icon">error_outline</i>
<div v-if="$store.state.error" class="uk-align-center">
{{ $store.state.error }}
</div>
<div class="uk-align-center">
<devTools class="uk-width-medium"></devTools>
</div>
</div>
</template>
<script>
import devTools from "./controlComponents/devTools.vue";
// Export main app
export default {
name: "LoadingContent",
components: { devTools },
data: function() {
return {};
}
};
</script>
<style scoped lang="less">
.error-icon {
font-size: 120px;
}
</style>

View file

@ -0,0 +1,318 @@
<template>
<div id="modal-example" ref="calibrationModalEl" uk-modal="bg-close: false;">
<div v-if="ready" class="uk-modal-dialog uk-modal-body">
<h2 class="uk-modal-title">Microscope Calibration</h2>
<div v-show="stepValue == 0">
<p>
<b
>Some important microscope calibration data is currently missing.</b
>
</p>
<p>
Your microscope will still function, however some functionality will
be limited, and image quality will likely suffer.
</p>
<p>
<b>Click Next to begin microscope calibration.</b>
</p>
</div>
<div v-show="stepValue == 1">
<h3>Lens-shading</h3>
<div v-if="isLSTCalibrated">
<p>
<b
>Your lens-shading table has already been calibrated. Click Next
to move on.</b
>
</p>
</div>
<div v-else>
<p>
<b
>Follow the important steps below before starting lens-shading
calibration!</b
>
</p>
<ul class="uk-list uk-list-bullet">
<li>Remove any samples from your microscope</li>
<li>Ensure your illumination is on and properly fixed in place</li>
</ul>
<miniStreamDisplay
v-if="stepValue == 1"
class="mini-preview"
></miniStreamDisplay>
<p>Once you're ready, click auto-calibrate.</p>
<cameraCalibrationSettings
:show-extra-settings="false"
></cameraCalibrationSettings>
</div>
</div>
<div v-show="stepValue == 2">
<h3>Camera-stage mapping</h3>
<div v-if="isCSMCalibrated">
<p>
<b
>Your camera-stage mapping has already been calibrated. Click Next
to move on.</b
>
</p>
</div>
<div v-else-if="!canCSMCalibrated">
<p>
<b
>No stage connected. Please skip this step, or connect a valid
stage, then reboot your microscope.</b
>
</p>
</div>
<div v-else>
<p>
<b
>Follow the important steps below before starting camera-stage
mapping calibration!</b
>
</p>
<ul class="uk-list uk-list-bullet">
<li>Insert a clearly visible sample to the microscope</li>
<li>
Ensure the sample is reasonably well centered on the microscope
camera
</li>
</ul>
<miniStreamDisplay
v-if="stepValue == 2"
class="mini-preview"
></miniStreamDisplay>
<p>Once you're ready, click auto-calibrate.</p>
<cameraStageMappingSettings
:show-extra-settings="false"
></cameraStageMappingSettings>
</div>
</div>
<div v-show="stepValue == 3">
<p>
<b>Calibration complete</b>
</p>
<p>
Click Finish to return to your microscope, or Restart to re-run the
calibration routine
</p>
</div>
<p class="uk-text-right">
<button
v-show="stepValue == 0"
class="uk-button uk-button-default uk-modal-close"
type="button"
>
Cancel
</button>
<button
v-show="stepValue == 3"
class="uk-button uk-button-default"
type="button"
@click="stepValue = 0"
>
Restart
</button>
<button
v-show="stepValue < 3"
class="uk-button uk-button-primary uk-margin-left"
type="button"
@click="increment()"
>
Next
</button>
<button
v-show="stepValue == 3"
class="uk-button uk-button-primary uk-margin-left"
type="button"
@click="hide()"
>
Finish
</button>
</p>
</div>
</div>
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "../viewComponents/settingsComponents/cameraCalibrationSettings.vue";
import cameraStageMappingSettings from "../viewComponents/settingsComponents/cameraStageMappingSettings.vue";
import miniStreamDisplay from "../viewComponents/miniStreamDisplay.vue";
export default {
name: "CalibrationModal",
components: {
cameraCalibrationSettings,
cameraStageMappingSettings,
miniStreamDisplay
},
props: {
availablePlugins: {
type: Array,
required: true
}
},
data: function() {
return {
ready: false,
stepValue: 0,
settings: {},
config: {}
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
availablePluginTitles: function() {
return this.availablePlugins.map(a => a.title);
},
isCSMCalibrated: function() {
if (this.settings.extensions["org.openflexure.camera_stage_mapping"]) {
return true;
} else {
return false;
}
},
isLSTCalibrated: function() {
if (
this.settings.camera.picamera &&
this.settings.camera.picamera.lens_shading_table
) {
return true;
} else {
return false;
}
},
canCSMCalibrated: function() {
// Assert CSM extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.camera_stage_mapping"
);
// Assert real stage is connected
var stageConnected = this.config.stage.type !== "MissingStage";
// Combine
return stageConnected && extensionEnabled;
},
canLSTCalibrated: function() {
// Assert LST extension is enabled
var extensionEnabled = this.availablePluginTitles.includes(
"org.openflexure.calibration.picamera"
);
// Assert real camera is connected
var cameraConnected = this.config.camera.type !== "MissingCamera";
// Combine
return cameraConnected && extensionEnabled;
},
isUseful: function() {
var CSMUseful = this.canCSMCalibrated && !this.isCSMCalibrated;
var LSTUseful = this.canLSTCalibrated && !this.isLSTCalibrated;
return CSMUseful || LSTUseful;
}
},
mounted() {
this.$refs["calibrationModalEl"].addEventListener("hidden", this.onHide);
},
methods: {
show: function() {
// Get current settings
this.getSettings()
.then(() => {
return this.getConfig();
})
.then(() => {
// Check if this calibration wizard can actually do anything useful
if (this.isUseful) {
this.ready = true;
this.stepValue = 0;
// Show the modal
var el = this.$refs["calibrationModalEl"];
this.showModalElement(el); // Calls the mixin
} else {
// If not useful, we just return the onClose event immediately
this.onHide();
}
});
},
hide: function() {
// Show the modal
var el = this.$refs["calibrationModalEl"];
this.hideModalElement(el); // Calls the mixin
this.ready = false;
},
onHide: function() {
console.log("UIKit modal hidden");
this.$emit("onClose");
},
getSettings: function() {
return axios
.get(this.settingsUri)
.then(response => {
this.settings = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getConfig: function() {
return axios
.get(this.configUri)
.then(response => {
this.config = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
decrement: function() {
if (this.stepValue > 0) {
this.stepValue = this.stepValue - 1;
}
},
increment: function() {
// Upper bound on section number
if (this.stepValue < 3) {
this.stepValue = this.stepValue + 1;
return true;
}
}
}
};
</script>
<style scoped>
.mini-preview {
width: 75%;
margin-left: auto;
margin-right: auto;
border: 1px solid #555;
}
</style>

View file

@ -0,0 +1,257 @@
<template>
<div class="uk-padding-small">
<div class="uk-flex">
<div class="uk-text-bold uk-text-uppercase uk-width-expand">
{{ name }}
</div>
<a href="#" class="uk-icon uk-width-auto" @click="updateForm()"
><i class="material-icons">cached</i></a
>
</div>
<form ref="formContainer" class="uk-form-stacked" @submit.prevent="">
<div v-for="(field, index) in schema" :key="index">
<div
v-if="Array.isArray(field)"
class="uk-grid-small uk-width-1-1 uk-child-width-expand"
uk-grid
>
<div v-for="(subfield, subindex) in field" :key="subindex">
<component
:is="subfield.fieldType"
v-model="formData[subfield.name]"
v-bind="subfield"
>
</component>
</div>
</div>
<component
:is="field.fieldType"
v-model="formData[field.name]"
v-bind="field"
>
</component>
</div>
<div v-if="isTask" class="uk-margin">
<taskSubmitter
:submit-url="submitApiUri"
:submit-data="formData"
:submit-label="submitLabel"
@submit="onTaskSubmit"
@response="onTaskResponse"
@error="onTaskError"
>
</taskSubmitter>
</div>
<div v-else class="uk-margin">
<button
type="button"
class="uk-button uk-button-primary uk-width-1-1"
@click="newQuickRequest(formData)"
>
{{ submitLabel }}
</button>
</div>
</form>
</div>
</template>
<script>
import axios from "axios";
import numberInput from "../fieldComponents/numberInput";
import selectList from "../fieldComponents/selectList";
import textInput from "../fieldComponents/textInput";
import htmlBlock from "../fieldComponents/htmlBlock";
import radioList from "../fieldComponents/radioList";
import checkList from "../fieldComponents/checkList";
import tagList from "../fieldComponents/tagList";
import keyvalList from "../fieldComponents/keyvalList";
import taskSubmitter from "../genericComponents/taskSubmitter";
export default {
name: "JsonForm",
components: {
numberInput,
selectList,
textInput,
htmlBlock,
radioList,
checkList,
tagList,
keyvalList,
taskSubmitter
},
props: {
name: {
type: String,
required: false,
default: "Plugin"
},
schema: {
type: Array,
required: true
},
route: {
type: String,
required: true
},
isTask: {
type: Boolean,
required: false,
default: false
},
submitLabel: {
type: String,
required: false,
default: "Submit"
},
emitOnResponse: {
type: String,
required: false,
default: null
}
},
data: function() {
return {
formData: {}
};
},
computed: {
pluginApiUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
},
submitApiUri: function() {
// TODO: This could probably be handled more explicitally
return this.pluginApiUri + this.route;
}
},
watch: {
// Whenever the form schema updates, re-check server for field values
schema: function() {
this.updateFormValues();
}
},
created() {
this.initialiseFormData();
},
methods: {
initialiseFormData() {
/*
This function initialises the form data.
Limitations in Vue mean that newly created formData properties are not reactive.
GETting formData values from the server creates the properties, but the form components
don't get updated because of this limitation.
Here, we step through the form schema, and properly create reactive properties for each component.
*/
for (const field of this.schema) {
if (Array.isArray(field)) {
var defaultValue; // Initial value of the form component
for (const subfield of field) {
// If a default value is given in the schema, use this
if (subfield.value) {
defaultValue = subfield.value;
} else if (subfield.default) {
defaultValue = subfield.default;
} else {
defaultValue = null;
}
this.$set(this.formData, subfield.name, defaultValue);
}
} else {
// If a default value is given in the schema, use this
if (field.value) {
defaultValue = field.value;
} else if (field.default) {
defaultValue = field.default;
} else {
defaultValue = null;
}
this.$set(this.formData, field.name, defaultValue);
}
}
},
updateFormValues() {
for (const field of this.schema) {
if (Array.isArray(field)) {
for (const subfield of field) {
// If a default value is given in the schema, use this
if (subfield.value) {
this.$set(this.formData, subfield.name, subfield.value);
}
}
} else {
if (field.value) {
this.$set(this.formData, field.name, field.value);
}
}
}
},
updateForm() {
// Trigger a plugin form update
console.log("Emitting reloadForms");
this.$emit("reloadForms");
},
onSubmissionCompleted: function() {
if (this.emitOnResponse) {
this.$root.$emit(this.emitOnResponse);
}
this.updateForm();
},
newQuickRequest: function(params) {
console.log(this.submitApiUri);
console.log(params);
// Send a quick request
axios
.post(this.submitApiUri, params)
.then(response => {
// Do something with the response
console.log(response);
// Do all the finished request stuff
this.onSubmissionCompleted();
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onTaskSubmit: function() {},
onTaskResponse: function(responseData) {
console.log("Task finished with response data: ", responseData);
// Do all the finished request stuff
this.onSubmissionCompleted();
},
onTaskError: function(error) {
this.modalError(error);
}
}
};
</script>
<style scoped>
.flex-container {
display: flex;
}
.flex-container > div {
flex-basis: 100%;
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="uk-padding-small">
<div v-if="error" class="uk-padding-small uk-text-danger">
<b>{{ error }}</b>
</div>
<component
:is="componentName"
v-if="ready"
:component-base-u-r-l="ApiRootURL"
></component>
</div>
</template>
<script>
export default {
name: "WebComponentLoader",
props: {
componentURL: {
type: String,
required: true
},
componentName: {
type: String,
required: true
}
},
data: function() {
return {
ready: false,
error: null
};
},
computed: {
ApiRootURL: function() {
return `${this.$store.getters.baseUri}/api/v2`;
}
},
mounted() {
// Method 2
this.$loadScript(this.componentURL)
.then(() => {
const el = customElements.get(this.componentName);
if (el) {
this.ready = true;
} else {
this.error = `Error: No component ${this.componentName} found.`;
}
this.ready = true;
})
.catch(() => {
this.ready = false;
});
}
};
</script>
<style scoped></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-jsclient/-/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 "../controlComponents/devTools.vue";
import statusPane from "../viewComponents/statusPane.vue";
export default {
name: "AboutContent",
components: {
devTools,
statusPane
}
};
</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">
<paneCapture />
</div>
<div class="view-component uk-width-expand">
<streamDisplay />
</div>
</div>
</template>
<script>
import paneCapture from "../controlComponents/paneCapture";
import streamDisplay from "../viewComponents/streamDisplay.vue";
export default {
name: "NavigateContent",
components: {
paneCapture,
streamDisplay
}
};
</script>

View file

@ -0,0 +1,89 @@
<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>
<div v-else-if="webComponent">
<WebComponentLoader
:component-u-r-l="webComponent.href"
:component-name="webComponent.name"
/>
</div>
<!-- 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">
<galleryDisplay v-if="viewPanel == 'gallery'" />
<settingsDisplay v-else-if="viewPanel == 'settings'" />
<streamDisplay v-else />
</div>
</div>
</template>
<script>
import JsonForm from "../pluginComponents/JsonForm";
import WebComponentLoader from "../pluginComponents/WebComponentLoader";
import streamDisplay from "../viewComponents/streamDisplay.vue";
import galleryDisplay from "../viewComponents/galleryDisplay.vue";
import settingsDisplay from "../viewComponents/settingsDisplay.vue";
export default {
name: "ExtensionContent",
components: {
JsonForm,
WebComponentLoader,
streamDisplay,
galleryDisplay,
settingsDisplay
},
props: {
forms: {
type: Array,
required: false,
default: () => []
},
webComponent: {
type: Object,
required: false,
default: null
},
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,19 @@
<template>
<!-- Grid managing tab content -->
<div class="view-component uk-height-1-1 uk-width-expand">
<galleryDisplay />
</div>
</template>
<script>
import galleryDisplay from "../viewComponents/galleryDisplay.vue";
export default {
name: "NavigateContent",
components: {
galleryDisplay
}
};
</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 "../controlComponents/paneNavigate";
import streamDisplay from "../viewComponents/streamDisplay.vue";
export default {
name: "NavigateContent",
components: {
paneNavigate,
streamDisplay
}
};
</script>

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 uk-padding-small">
<settingsDisplay />
</div>
</div>
</template>
<script>
import settingsDisplay from "../viewComponents/settingsDisplay.vue";
export default {
name: "NavigateContent",
components: {
settingsDisplay
}
};
</script>

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 "../viewComponents/streamDisplay.vue";
export default {
name: "ViewContent",
components: {
streamDisplay
}
};
</script>

View file

@ -0,0 +1,168 @@
<template>
<div
class="hostCard uk-card uk-card-default uk-padding-remove uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.globalSettings.darkMode }"
>
<div class="uk-card-media-top">
<div class="snapshot-container uk-width-1-1 uk-height-auto">
<img
class="uk-width-1-1"
:data-src="snapshotSrc"
:src="snapshotSrc"
width="300"
height="225"
/>
<div
v-if="!snapshotAvailable"
class="uk-position-cover uk-flex uk-flex-center uk-flex-middle"
>
Snapshot unavailable
</div>
</div>
</div>
<div class="uk-card-body uk-padding-small">
<div
class="uk-margin-small uk-margin-remove-left uk-margin-remove-right uk-flex uk-flex-middle"
>
<div class="uk-width-expand host-description">
<div class="host-description">
<b>{{ name }}</b>
</div>
<div class="host-description">{{ hostname }}:{{ port }}</div>
</div>
<a
v-if="deletable"
href="#"
class="uk-icon uk-width-auto host-delete"
@click="$emit('delete')"
><i class="material-icons">delete</i></a
>
</div>
</div>
<div class="uk-card-footer uk-padding-small">
<button
class="uk-button uk-button-default uk-form-small uk-float-right uk-margin uk-margin-remove-top uk-width-1-1"
@click="$emit('connect')"
>
Connect
</button>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "HostCard",
props: {
name: {
type: String,
required: true
},
hostname: {
type: String,
required: true
},
port: {
type: Number,
required: true
},
deletable: {
type: Boolean,
required: false,
default: true
}
},
data: function() {
return {
snapshotRule: "/api/v2/streams/snapshot",
snapshotSrc: "",
snapshotAvailable: false,
polling: null
};
},
computed: {
routesURL: function() {
return `http://${this.hostname}:${this.port}/routes`;
},
snapshotURL: function() {
return `http://${this.hostname}:${this.port}${this.snapshotRule}`;
}
},
mounted() {
// Try to load the microscope snapshot when mounted
this.checkSnapshotAvailable();
},
beforeDestroy() {
// Clear the polling timer
if (this.polling !== null) {
clearInterval(this.polling);
}
},
methods: {
checkSnapshotAvailable: function() {
// Fetches the list of routes from the host, and starts
// a snapshot timer if the snapshot route is available
// Send tag request
axios
.get(this.routesURL)
.then(response => {
// If the host has a snapshot route available
if (this.snapshotRule in response.data) {
// Tell the view that a snapshot is available
this.snapshotAvailable = true;
// Update the snapshot URL
this.updateSnapshotURL();
// If not already polling, start polling
if (this.polling === null) {
this.updateSnapshotPoll();
}
}
})
.catch(() => {
// Tell the view that a snapshot is not available, and ignore
this.snapshotAvailable = false;
});
},
updateSnapshotURL: function() {
// Set the snapshot image src to the snapshot URL,
// adding a timestamp argument to act as a cache-breaker
this.snapshotSrc = `${this.snapshotURL}?${Date.now()}`;
},
updateSnapshotPoll: function() {
// Start a timer to call updateSnapshotURL periodically
this.polling = setInterval(() => {
this.updateSnapshotURL();
}, 15000);
}
}
};
</script>
<style>
.host-description {
text-overflow: ellipsis;
overflow: hidden;
}
.snapshot-container {
position: relative;
}
img[data-src][src*="data:image"] {
background: rgba(127, 127, 127, 0.1);
}
</style>

View file

@ -0,0 +1,329 @@
<template>
<div
class="capture-card uk-card uk-card-default uk-padding-remove uk-width-medium"
:class="{ 'uk-card-secondary': $store.state.globalSettings.darkMode }"
>
<div class="uk-card-media-top">
<a class="lightbox-link" :href="imgURL" :data-caption="fileName">
<img
class="uk-width-1-1"
:data-src="thumbURL"
:alt="captureState.metadata.image.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">
{{ fileName }}
</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>{{ captureState.metadata.image.acquisitionDate }}</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">
<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.globalSettings.darkMode
}"
>
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="uk-modal-title">{{ fileName }}</h2>
<p><b>Path: </b>{{ captureState.path }}</p>
<p><b>Time: </b>{{ captureState.metadata.image.acquisitionDate }}</p>
<p><b>ID: </b>{{ captureState.metadata.image.id }}</p>
<p><b>Format: </b>{{ captureState.metadata.image.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.globalSettings.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 keyvalList from "../../fieldComponents/keyvalList";
// Export main app
export default {
name: "CaptureCard",
components: {
keyvalList
},
props: {
captureState: {
type: Object,
required: true
}
},
data: function() {
return {
tags: [],
newTag: "",
annotations: {},
newAnnotations: {}
};
},
computed: {
fileName: function() {
return this.captureState.name // If this.captureState.filename exists
? this.captureState.name // Use this.captureState.filename
: this.captureState.metadata.filename; // Otherwise use old this.captureState.metadata.filename
},
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.captureState.links.download.href}?thumbnail=true`;
},
imgURL: function() {
return this.captureState.links.download.href;
},
tagsURL: function() {
return this.captureState.links.tags.href;
},
annotationsURL: function() {
return this.captureState.links.annotations.href;
},
captureURL: function() {
return this.captureState.links.self.href;
}
},
created: function() {
this.getTagRequest();
this.getAnnotationsRequest();
},
methods: {
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) {
console.log(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 => {
this.tags = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
getAnnotationsRequest: function() {
// Send tag request
axios
.get(this.annotationsURL)
.then(response => {
this.annotations = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
makeModalName: function(prefix) {
return prefix + this.captureState.metadata.image.id;
}
}
};
</script>

View file

@ -0,0 +1,149 @@
<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="metadata.image.id"
width="300"
height="225"
uk-img
@click="$root.$emit('globalUpdateCaptureFolder', metadata.image.id)"
/>
</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>{{ metadata.type || "Dataset" }}: </b> {{ metadata.image.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>{{ metadata.image.acquisitionDate }}</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">
<span
v-for="tag in metadata.image.tags"
:key="tag"
class="uk-label uk-margin-small-right deletable-label"
>
{{ tag }}
</span>
</div>
<div :id="metadataModalID" uk-modal>
<div class="uk-modal-dialog uk-modal-body">
<button class="uk-modal-close-default" type="button" uk-close></button>
<h2 class="uk-modal-title">{{ metadata.image.name }}</h2>
<p><b>Time: </b>{{ metadata.image.acquisitionDate }}</p>
<p><b>ID: </b>{{ metadata.image.id }}</p>
<hr />
<div v-for="(value, key) in metadata.image.annotations" :key="key">
<p>
<b>{{ key }}: </b>{{ value }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
// Export main app
export default {
name: "ScanCard",
props: {
metadata: {
type: Object,
required: true
},
thumbnail: {
type: String,
required: true
},
captures: {
type: Array,
required: true
}
},
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;
},
allURLs: function() {
var urls = [];
for (var capture of this.captures) {
urls.push(capture.links.self.href);
}
return urls;
}
},
methods: {
makeModalName: function(prefix) {
return prefix + this.metadata.image.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
//axios.spread(function(...res) {
// all requests are now complete
//console.log(res);
//})
()
.then(() => {
// Emit signal to update capture list
this.$root.$emit("globalUpdateCaptures");
});
}
}
};
</script>

View file

@ -0,0 +1,184 @@
<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-form-small 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"
@submit="onSubmit"
@response="onResponse"
@error="onError"
>
</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)
.then(response => {
console.log(response);
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
onResponse: function(response) {
this.lastSessionId = response.return.id;
this.downloadUrl = `${this.zipGetterUri}/${this.lastSessionId}`;
this.downloadReady = true;
},
onSubmit: function(submitData) {
console.log("SUBMITTED");
console.log(submitData);
},
onError: function(error) {
this.modalError(error); // Let mixin handle error
}
}
};
</script>

View file

@ -0,0 +1,409 @@
<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-navbar-dropdown"
:class="{
'uk-light uk-background-secondary':
$store.state.globalSettings.darkMode
}"
>
<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"
class="uk-checkbox"
type="checkbox"
:value="tag"
checked
/>
{{ tag }}
</label>
</div>
</form>
</ul>
</div>
</li>
</ul>
</div>
<!-- Right side buttons -->
<div class="uk-navbar-right">
<div class="uk-grid">
<div>
<button
type="button"
class="uk-button uk-button-default uk-width-1-1"
@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
href="#"
class="uk-icon uk-margin-remove"
@click="galleryFolder = ''"
>
<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].metadata.image.name }}
</h3>
</div>
</div>
<!-- Gallery capture cards -->
<div class="gallery-grid">
<div v-for="item in sortedItems" :key="item.metadata.id">
<scanCard
v-if="'isScan' in item"
:metadata="item.metadata"
:thumbnail="item.thumbnail"
:captures="item.captures"
/>
<captureCard v-else :capture-state="item" />
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
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
},
data: function() {
return {
captures: [],
checkedTags: [],
sortDescending: true,
galleryFolder: "",
scanTag: "scan",
unwatchStoreFunction: null
};
},
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.metadata.image.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 (!capture.metadata.dataset) {
captures.push(capture);
}
}
return captures;
},
allScans: function() {
// List of scans as capture-like objects
var scans = {};
for (var capture of this.captures) {
var annotations = capture.metadata.image.annotations;
var tags = capture.metadata.image.tags;
var dataset = capture.metadata.dataset;
if (dataset) {
var id = dataset["id"];
// If this scan ID hasn't been seen before
if (!(id in scans)) {
scans[id] = {};
scans[id].isScan = true;
scans[id].captures = [];
scans[id].metadata = {
image: {
name: dataset.name,
acquisitionDate: dataset.acquisitionDate,
id: dataset.id,
tags: [],
annotations: {}
},
type: dataset.type
};
}
// Add the capture object to the scan
scans[id].captures.push(capture);
// Add missing scan metadata, prioritising first capture
for (var key of Object.keys(annotations)) {
if (!(key in scans[id].metadata.image.annotations)) {
scans[id].metadata.image.annotations[key] = annotations[key];
}
}
// Append missing tags
for (var tag of tags) {
if (!scans[id].metadata.image.tags.includes(tag)) {
scans[id].metadata.image.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) {
console.log(this.allScans[this.galleryFolder].captures);
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.metadata.image.id] = capture;
}
} else {
// If it's a single capture, get the ID of the capture
captures[item.metadata.image.id] = item;
}
}
return captures;
},
sortedItems: function() {
// Sort filteredItems using sortCaptures function
return this.sortCaptures(this.filteredItems);
}
},
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();
});
// A global signal listener to set the gallery folder
this.$root.$on("globalUpdateCaptureFolder", folder => {
this.galleryFolder = folder;
});
},
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");
// Remove global signal listener to set the gallery folder
this.$root.$off("globalUpdateCaptureFolder");
// Then we call that function here to unwatch
if (this.unwatchStoreFunction) {
this.unwatchStoreFunction();
this.unwatchStoreFunction = null;
}
},
methods: {
updateCaptures: function() {
if (this.$store.state.available) {
console.log("Updating capture list...");
axios
.get(this.capturesUri)
.then(response => {
this.captures = response.data;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
} else {
console.log("Delaying capture update until connection is available");
}
},
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.metadata.image.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.metadata.image.acquisitionDate < b.metadata.image.acquisitionDate)
return -1;
if (a.metadata.image.acquisitionDate > b.metadata.image.acquisitionDate)
return 1;
return 0;
}
if (this.sortDescending == true) {
return list.sort(compare).reverse();
} else {
return list.sort(compare);
}
}
}
};
</script>
<style scoped lang="less">
.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;
}
.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,52 @@
<template>
<div
id="stream-display"
ref="streamDisplay"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
>
<img
ref="click-frame"
class="uk-align-center uk-margin-remove-bottom"
:src="streamImgUri"
alt="Stream"
/>
</div>
</template>
<script>
// Export main app
export default {
name: "MiniStreamDisplay",
data: function() {
return {};
},
computed: {
streamImgUri: function() {
return `${this.$store.getters.baseUri}/api/v2/streams/mjpeg`;
}
},
methods: {}
};
</script>
<style scoped lang="less">
.stream-display img {
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,51 @@
<template>
<div id="appSettings">
<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.globalSettings.appTheme;
},
set(value) {
this.$store.commit("changeSetting", ["appTheme", value]);
}
}
},
watch: {
appTheme() {
console.log("Saving appTheme setting");
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,118 @@
<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="'Auto-Calibrate'"
@response="onRecalibrateResponse"
@error="onRecalibrateError"
>
</taskSubmitter>
</div>
<div v-show="showExtraSettings" class="uk-child-width-expand" uk-grid>
<div v-if="'flatten_lens_shading_table' in recalibrationLinks">
<button
class="uk-button uk-button-danger uk-width-1-1"
@click="flattenLensShadingTableRequest"
>
Disable flat-field correction
</button>
</div>
<div v-if="'delete_lens_shading_table' in recalibrationLinks">
<button
class="uk-button uk-button-danger uk-width-1-1"
@click="deleteLensShadingTableRequest"
>
Adaptive flat-field correction
</button>
</div>
</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
};
},
computed: {
pluginsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/extensions`;
}
},
mounted() {
this.updateRecalibrationLinks();
},
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
});
},
onRecalibrateResponse: function() {
this.modalNotify("Finished recalibration.");
},
onRecalibrateError: function(error) {
this.modalError(error); // Let mixin handle error
},
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,136 @@
<template>
<div v-if="settings" id="cameraSettings">
<div>
<h3>Manual camera settings</h3>
<form @submit.prevent="applyConfigRequest">
<div v-if="settings.picamera">
<!--PiCamera settings block-->
<div v-if="settings.picamera.shutter_speed !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Exposure time</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.shutter_speed"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
<div v-if="settings.picamera.analog_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Analogue gain</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.analog_gain"
class="uk-input uk-form-small"
type="number"
step="0.000001"
/>
</div>
</div>
<div v-if="settings.picamera.digital_gain !== undefined">
<label class="uk-form-label" for="form-stacked-text"
>Digital gain</label
>
<div class="uk-form-controls">
<input
v-model="settings.picamera.digital_gain"
class="uk-input uk-form-small"
type="number"
step="0.000001"
/>
</div>
</div>
</div>
<button
type="submit"
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
>
Apply Settings
</button>
</form>
</div>
<!--Show auto calibrate if default plugin is enabled-->
<h3>Automatic calibration</h3>
<cameraCalibrationSettings></cameraCalibrationSettings>
</div>
</template>
<script>
import axios from "axios";
import cameraCalibrationSettings from "./cameraCalibrationSettings.vue";
// Export main app
export default {
name: "CameraSettings",
components: {
cameraCalibrationSettings
},
data: function() {
return {
settings: {}
};
},
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.camera;
})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
},
applyConfigRequest: function() {
console.log("Applying config to the microscope");
var payload = {
camera: {
picamera: {
shutter_speed: this.settings.picamera.shutter_speed,
analog_gain: this.settings.picamera.analog_gain,
digital_gain: this.settings.picamera.digital_gain
}
}
};
// 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"></style>

View file

@ -0,0 +1,170 @@
<template>
<div id="cameraStageMappingSettings">
<form @submit.prevent="applyConfigRequest">
<!--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="onRecalibrateError"
>
</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>
</form>
</div>
</template>
<script>
import axios from "axios";
import taskSubmitter from "../../genericComponents/taskSubmitter";
// Export main app
export default {
name: "CameraStageMappingSettings",
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 => {
console.log("CSM data:");
console.log(response.data);
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;
}
</style>

View file

@ -0,0 +1,134 @@
<template>
<div v-if="settings" id="microscopeSettings">
<form @submit.prevent="applyConfigRequest">
<label class="uk-form-label" for="form-stacked-text"
>Backlash compensation</label
>
<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="settings.stage.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="settings.stage.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="settings.stage.backlash.z"
class="uk-input uk-form-small"
type="number"
/>
</div>
</div>
</div>
<br />
<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-form-small uk-float-right 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
});
},
applyConfigRequest: function() {
var payload = {
name: this.settings.name,
stage: {
backlash: this.settings.stage.backlash
}
};
console.log(payload);
// 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,78 @@
<template>
<div id="streamSettings">
<div>
<h3>Stream settings</h3>
<label
><input v-model="disableStream" class="uk-checkbox" type="checkbox" />
Disable live stream</label
>
</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>
</div>
</div>
</template>
<script>
// Export main app
export default {
name: "StreamSettings",
data: function() {
return {};
},
computed: {
disableStream: {
get() {
return this.$store.state.globalSettings.disableStream;
},
set(value) {
this.$store.commit("changeSetting", ["disableStream", value]);
}
},
autoGpuPreview: {
get() {
return this.$store.state.globalSettings.autoGpuPreview;
},
set(value) {
this.$store.commit("changeSetting", ["autoGpuPreview", value]);
this.$root.$emit("globalSafeTogglePreview", value);
}
},
trackWindow: {
get() {
return this.$store.state.globalSettings.trackWindow;
},
set(value) {
this.$store.commit("changeSetting", ["trackWindow", value]);
}
}
}
};
</script>
<style lang="less"></style>

View file

@ -0,0 +1,173 @@
<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 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-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="camera"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<cameraSettings />
</div>
</tabContent>
<tabContent
tab-i-d="mapping"
:require-connection="true"
:current-tab="currentTab"
>
<div class="settings-pane uk-padding-small">
<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>
<cameraStageMappingSettings />
</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 cameraStageMappingSettings from "./settingsComponents/cameraStageMappingSettings.vue";
// Import generic components
import tabIcon from "../genericComponents/tabIcon";
import tabContent from "../genericComponents/tabContent";
// Export main app
export default {
name: "SettingsDisplay",
components: {
streamSettings,
cameraSettings,
microscopeSettings,
cameraStageMappingSettings,
appSettings,
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">
.settings-pane {
border-width: 0 1px 0 0;
border-style: solid;
border-color: rgba(180, 180, 180, 0.25);
}
.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);
}
</style>

View file

@ -0,0 +1,202 @@
<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>
<div>
<b>Client version:</b> <br />
{{ clientVersionName }}
</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: {},
clientVersion: process.env.PACKAGE.version
};
},
computed: {
settingsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
},
configurationUri: function() {
return `${this.$store.getters.baseUri}/api/v2/instrument/configuration`;
},
actionsUri: function() {
return `${this.$store.getters.baseUri}/api/v2/actions`;
},
clientVersionName: function() {
return `${process.env.PACKAGE.version}`;
}
},
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.actionsUri)
.then(response => {
if ("reboot" in response.data) {
this.$set(
this.systemActionLinks,
"reboot",
response.data.reboot.links.self.href
);
} else {
delete this.systemActionLinks.reboot;
}
if ("shutdown" in response.data) {
this.$set(
this.systemActionLinks,
"shutdown",
response.data.shutdown.links.self.href
);
} 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");
axios.post(this.systemActionLinks.shutdown).catch(error => {
console.log(error); // Be quiet when empty response is recieved
});
}
},
() => {}
);
},
rebootRequest: function() {
this.modalConfirm("Restart microscope?").then(
() => {
if ("reboot" in this.systemActionLinks) {
this.$store.commit("resetState");
axios.post(this.systemActionLinks.reboot).catch(error => {
console.log(error); // Be quiet when empty response is recieved
});
}
},
() => {}
);
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="less"></style>

View file

@ -0,0 +1,330 @@
<template>
<div
id="stream-display"
ref="streamDisplay"
class="stream-display uk-width-1-1 uk-height-1-1 scrollTarget"
>
<img
v-if="thisStreamOpen"
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.globalSettings.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 {
displaySize: [0, 0],
displayPosition: [0, 0],
fov: [0, 0],
resizeTimeoutId: setTimeout(this.doneResizing, 500)
};
},
computed: {
streamEnabled: function() {
return (
this.$store.getters.ready &&
!this.$store.state.globalSettings.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`;
}
},
mounted() {
console.log(`${this._uid} mounted`);
// A global signal listener to change the GPU preview state
this.$root.$on("globalTogglePreview", state => {
this.previewRequest(state);
});
this.$root.$on("globalSafeTogglePreview", state => {
this.safePreviewRequest(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() {
console.log(`${this._uid} created`);
// Send a request to start/stop GPU preview based on global setting
this.safePreviewRequest(this.$store.state.globalSettings.autoGpuPreview);
// Get FOV from settings
this.updateFov();
},
beforeDestroy: function() {
console.log(`${this._uid} being destroyed`);
// 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: {
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");
},
clickMonitor: function(event) {
// Calculate steps from event coordinates and store config FOV
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
console.log(`Recalculating frame size for ${this._uid}`);
this.recalculateSize();
// Handle closed stream
if (this.displaySize[0] == 0 && this.displaySize[1] == 0) {
console.log(`${this._uid} is zero`);
// If this stream was previously active
if (this.$store.state.activeStreams[this._uid] == true) {
console.log(`${this._uid} was active`);
console.log(`STREAM ${this._uid} CLOSED`);
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 {
console.log(`STREAM ${this._uid} OPEN`);
this.$store.commit("addStream", this._uid);
if (this.$store.state.globalSettings.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) {
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.globalSettings.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
console.log(`${this._uid} toggled preview to ${state}`);
axios
.post(requestUri, payload)
.then(() => {})
.catch(error => {
this.modalError(error); // Let mixin handle error
});
}
},
updateFov: function() {
console.log("Updating FOV");
// Get the current field-of-view setting from the server
axios
.get(`${this.settingsUri}/fov`)
.then(response => {
if (response.data) {
this.fov = response.data;
}
})
.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,154 @@
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import UIkit from "uikit";
import VueTour from "vue-tour";
import LoadScript from "vue-plugin-load-script";
import VueFriendlyIframe from "vue-friendly-iframe";
require("vue-tour/dist/vue-tour.css");
// Import MD icons
import "material-design-icons/iconfont/material-icons.css";
// UIKit overrides
UIkit.mixin(
{
data: {
animation: false
}
},
"accordion"
);
// Use load-script module
Vue.use(LoadScript);
// Use vue-tour module
Vue.use(VueTour);
// Use Friendly Iframe module
Vue.use(VueFriendlyIframe);
Vue.config.productionTip = false;
Vue.mixin({
methods: {
modalConfirm: function(modalText) {
var context = this;
// Stop GPU preview to show modal
context.$root.$emit("globalTogglePreview", false);
var showModal = function(resolve, reject) {
UIkit.modal
.confirm(modalText, { stack: true })
.then(
function() {
resolve();
},
function() {
reject();
}
)
.finally(function() {
// Reenable the GPU preview, if it was active before the modal
console.log("Re-enabling GPU preview");
if (context.$store.state.globalSettings.autoGpuPreview) {
console.log("Re-enabling preview");
context.$root.$emit("globalTogglePreview", true);
}
});
};
return new Promise(showModal);
},
modalNotify: function(message, status = "success") {
UIkit.notification({
message: message,
status: status
});
},
modalDialog: function(title, message) {
UIkit.modal.dialog(
`
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">${title}</h2>
</div>
<div class="uk-modal-body">
<p>${message}</p>
</div>
`,
{ stack: true }
);
},
modalError: function(error) {
var errormsg = this.getErrorMessage(error);
this.$store.commit("setErrorMessage", errormsg);
UIkit.notification({
message: `${errormsg}`,
status: "danger"
});
},
getErrorMessage: function(error) {
var errormsg = "";
// If a response was obtained
if (error.response) {
// If the response is a nicely formatted JSON response from the server
if (error.response.data.message) {
errormsg = `${error.response.status}: ${error.response.data.message}`;
console.log(errormsg);
}
// If the response is just some generic error response
else {
errormsg = `${error.response.status}: ${error.response.data}`;
console.log(errormsg);
}
// If the error occured during the request
} else if (error.request) {
errormsg = `${error.message}`;
console.log(errormsg);
// Everything else
} else {
errormsg = `${error.message}`;
console.log(errormsg);
}
return errormsg;
},
showModalElement: function(element) {
UIkit.modal(element).show();
},
hideModalElement: function(element) {
UIkit.modal(element).hide();
},
getLocalStorageObj: function(keyName) {
if (localStorage.getItem(keyName)) {
try {
return JSON.parse(localStorage.getItem(keyName));
} catch (e) {
console.log("Malformed entry. Removing from localStorage");
localStorage.removeItem(keyName);
return null;
}
}
},
setLocalStorageObj: function(keyName, object) {
const parsed = JSON.stringify(object);
localStorage.setItem(keyName, parsed);
}
}
});
new Vue({
store,
render: h => h(App)
}).$mount("#app");

View file

@ -0,0 +1,58 @@
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
origin: window.location.origin,
available: false,
waiting: false,
error: "",
globalSettings: {
disableStream: false,
autoGpuPreview: false,
trackWindow: true,
appTheme: "system"
},
activeStreams: {}
},
mutations: {
changeOrigin(state, origin) {
state.origin = origin;
},
changeWaiting(state, waiting) {
state.waiting = waiting;
},
changeSetting(state, [key, value]) {
state.globalSettings[key] = value;
},
resetState(state) {
state.waiting = false;
state.available = false;
state.error = null;
},
setConnected(state) {
state.waiting = false;
state.available = true;
},
setErrorMessage(state, msg) {
state.error = msg;
},
addStream(state, id) {
state.activeStreams[id] = true;
},
removeStream(state, id) {
state.activeStreams[id] = false;
}
},
actions: {},
getters: {
uriV2: state => `${state.origin}/api/v2`,
baseUri: state => state.origin,
ready: state => state.available
}
});