@@ -119,7 +107,13 @@ watch(darkMode, () => {
Dark Mode
-
+ appStore.theme = v ? 'dark' : 'light'"
+ >
@@ -172,12 +166,10 @@ watch(darkMode, () => {
}
.sidebar.closed {
- visibility: hidden;
- transition: max-height 0.15s ease-out;
+ max-height: 0;
}
.toolbar {
- visibility: visible;
border-radius: 10px 10px 0px 0px !important;
border-bottom: 1px solid rgb(var(--v-theme-border)) !important;
}
@@ -191,6 +183,7 @@ watch(darkMode, () => {
.sidebar.closed .toolbar {
border-radius: 10px !important;
width: fit-content !important;
+ position: absolute !important;
}
.sidebar.closed>.v-navigation-drawer__content>.toolbar>.v-toolbar__content {
diff --git a/web/src/main.ts b/web/src/main.ts
index 749069da..dc78bebb 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -12,7 +12,8 @@ import { useAppStore } from "@/store";
import "@mdi/font/css/materialdesignicons.css";
import { THEMES } from "./themes";
-import JsonEditorVue from 'json-editor-vue'
+import JsonEditorVue from 'json-editor-vue';
+import { createRouter, createWebHistory } from 'vue-router';
// Must first initialize pinia, so we can set the default theme
@@ -32,7 +33,20 @@ const vuetify = createVuetify({
app.use(vuetify)
app.use(JsonEditorVue);
-// Finally, mount the app
-restoreLogin().then(() => {
- app.mount("#app");
+// Initialize router
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [{
+ path: '/:pathMatch(.*)*',
+ name: 'Home',
+ component: App
+ },],
});
+app.use(router);
+await router.isReady()
+
+// Attempt login restoration
+await restoreLogin()
+
+// Finally, mount the app
+app.mount("#app");
diff --git a/web/src/store/analysis.ts b/web/src/store/analysis.ts
index d6c8b0f0..d3adef4e 100644
--- a/web/src/store/analysis.ts
+++ b/web/src/store/analysis.ts
@@ -1,4 +1,4 @@
-import { getProjectAnalysisTypes, getProjectCharts } from '@/api/rest';
+import { getProjectAnalysisTypes, getProjectCharts, getTaskResults } from '@/api/rest';
import { Chart, AnalysisType, TaskResult } from '@/types';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
@@ -11,6 +11,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
const loadingCharts = ref
(false);
const availableCharts = ref();
const currentChart = ref();
+ const currentAnalysisTab = ref<'old' | 'new'>('new')
const loadingAnalysisTypes = ref(false);
const availableAnalysisTypes = ref();
const currentAnalysisType = ref();
@@ -19,22 +20,24 @@ export const useAnalysisStore = defineStore('analysis', () => {
const ws = ref();
- function initCharts(projectId: number) {
+ async function initCharts(projectId: number) {
loadingCharts.value = true;
- getProjectCharts(projectId).then((charts) => {
- availableCharts.value = charts;
- currentChart.value = undefined;
- loadingCharts.value = false;
- });
+ const charts = await getProjectCharts(projectId)
+ availableCharts.value = charts;
+ currentChart.value = undefined;
+ loadingCharts.value = false;
}
- function initAnalysisTypes(projectId: number) {
+ async function initAnalysisTypes(projectId: number) {
loadingAnalysisTypes.value = true;
- getProjectAnalysisTypes(projectId).then((types) => {
- availableAnalysisTypes.value = types;
- currentAnalysisType.value = undefined;
- loadingAnalysisTypes.value = false;
- })
+ const types = await getProjectAnalysisTypes(projectId)
+ availableAnalysisTypes.value = types;
+ currentAnalysisType.value = undefined;
+ loadingAnalysisTypes.value = false;
+ }
+
+ async function initResults(analysisType: string, projectId: number) {
+ availableResults.value = await getTaskResults(analysisType, projectId)
}
function createWebSocket() {
@@ -75,6 +78,7 @@ export const useAnalysisStore = defineStore('analysis', () => {
loadingCharts,
availableCharts,
currentChart,
+ currentAnalysisTab,
loadingAnalysisTypes,
availableAnalysisTypes,
currentAnalysisType,
@@ -82,5 +86,6 @@ export const useAnalysisStore = defineStore('analysis', () => {
currentResult,
initCharts,
initAnalysisTypes,
+ initResults,
}
});
diff --git a/web/src/store/app.ts b/web/src/store/app.ts
index ff8342fc..239b492c 100644
--- a/web/src/store/app.ts
+++ b/web/src/store/app.ts
@@ -1,22 +1,29 @@
import { User } from '@/types';
import { defineStore } from 'pinia';
-import { ref } from 'vue';
+import { ref, watch } from 'vue';
export const useAppStore = defineStore('app', () => {
const theme = ref<"dark" | "light">("light");
const openSidebars = ref<("left" | "right")[]>(["left", "right"]);
const currentUser = ref();
const currentError = ref();
+ const themeManager = ref();
function setDefaultTheme() {
- const darkThemeMatch = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
- theme.value = darkThemeMatch ? "dark" : "light";
-
- return theme.value;
+ const darkThemeMatch = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ theme.value = darkThemeMatch ? "dark" : "light";
+ return theme.value;
}
+ watch(theme, () => {
+ if (themeManager.value) {
+ themeManager.value.change(theme.value);
+ }
+ })
+
return {
theme,
+ themeManager,
openSidebars,
currentUser,
currentError,
diff --git a/web/src/store/layer.ts b/web/src/store/layer.ts
index c79066c4..4e8ee046 100644
--- a/web/src/store/layer.ts
+++ b/web/src/store/layer.ts
@@ -131,9 +131,11 @@ export const useLayerStore = defineStore('layer', () => {
}
// Add this layer to selectedLayers, which will then trigger updateLayersShown to add it to the map
- async function addLayer(layer: Layer) {
+ async function addLayer(layer: Layer, copy_id: number | undefined = undefined) {
const existing = mapStore.getLatestLayerInstance(layer);
- const copy_id = existing === undefined ? 0 : existing.layerCopyId + 1;
+ if (copy_id === undefined) {
+ copy_id = existing === undefined ? 0 : existing.layerCopyId + 1;
+ }
const name = copy_id > 0 ? `${layer.name} (${copy_id})` : layer.name;
const newLayer = { ...layer, name, copy_id, visible: true, current_frame_index: 0 };
diff --git a/web/src/store/map.ts b/web/src/store/map.ts
index 029d78a5..b1678c62 100644
--- a/web/src/store/map.ts
+++ b/web/src/store/map.ts
@@ -21,7 +21,7 @@ import {
import { getBasemaps, getRasterDataValues } from '@/api/rest';
import { baseURL } from '@/api/auth';
import proj4 from 'proj4';
-import { useStyleStore, useLayerStore, useAppStore } from '.';
+import { useStyleStore, useLayerStore, useAppStore, useProjectStore } from '.';
function getLayerIsVisible(layer: MapLibreLayerWithMetadata) {
// Since visibility must be 'visible' for a feature click to even be registered,
@@ -139,6 +139,7 @@ export const useMapStore = defineStore('map', () => {
const styleStore = useStyleStore();
const layerStore = useLayerStore();
const appStore = useAppStore();
+ const projectStore = useProjectStore();
async function fetchAvailableBasemaps() {
availableBasemaps.value = [
@@ -150,6 +151,7 @@ export const useMapStore = defineStore('map', () => {
function setBasemapToDefault() {
if (!currentBasemap.value || currentBasemap.value.name.toLowerCase().includes('basic')) {
+ // @ts-ignore for "Type instantiation is excessively deep and possibly infinite"
currentBasemap.value = availableBasemaps.value.find((basemap) => {
return basemap.name.toLowerCase() === 'basic ' + appStore.theme
})
@@ -273,7 +275,20 @@ export const useMapStore = defineStore('map', () => {
return tooltipOverlay.value;
}
- function setMapCenter(
+ function setMapPosition(
+ center: [number, number],
+ zoom: number,
+ jump = false
+ ) {
+ const map = getMap();
+ if (jump) {
+ map.jumpTo({ center, zoom });
+ } else {
+ map.flyTo({ center, zoom, duration: 2000 });
+ }
+ }
+
+ function resetMapPosition(
project: Project | undefined = undefined,
jump = false
) {
@@ -283,13 +298,7 @@ export const useMapStore = defineStore('map', () => {
center = project.default_map_center;
zoom = project.default_map_zoom;
}
-
- const map = getMap();
- if (jump) {
- map.jumpTo({ center, zoom });
- } else {
- map.flyTo({ center, zoom, duration: 2000 });
- }
+ setMapPosition(center, zoom, jump)
}
function clearMapLayers() {
@@ -542,6 +551,13 @@ export const useMapStore = defineStore('map', () => {
}
}
+ watch(map, () => {
+ // Once map is initialized, attempt to load URL view
+ if (map.value) {
+ projectStore.loadViewStateFromURL()
+ }
+ })
+
return {
// Data
map,
@@ -567,7 +583,8 @@ export const useMapStore = defineStore('map', () => {
getMapSources,
getCurrentMapPosition,
getTooltip,
- setMapCenter,
+ setMapPosition,
+ resetMapPosition,
clearMapLayers,
removeLayers,
createVectorFeatureMapLayers,
diff --git a/web/src/store/network.ts b/web/src/store/network.ts
index 0a9fc0d7..5833809d 100644
--- a/web/src/store/network.ts
+++ b/web/src/store/network.ts
@@ -121,14 +121,13 @@ export const useNetworkStore = defineStore('network', () => {
});
// Actions
- function initNetworks(projectId: number) {
+ async function initNetworks(projectId: number) {
loadingNetworks.value = true;
- getProjectNetworks(projectId).then((networks) => {
- availableNetworks.value = networks;
- currentNetwork.value = undefined;
- loadingNetworks.value = false;
- networks.forEach((n) => resetNetworkState(n.id))
- })
+ const networks = await getProjectNetworks(projectId)
+ availableNetworks.value = networks;
+ currentNetwork.value = undefined;
+ loadingNetworks.value = false;
+ networks.forEach((n) => resetNetworkState(n.id))
}
function resetNetworkState(networkId: number) {
diff --git a/web/src/store/project.ts b/web/src/store/project.ts
index 9c9d32bf..a7aed71d 100644
--- a/web/src/store/project.ts
+++ b/web/src/store/project.ts
@@ -3,10 +3,14 @@ import {
getProjectDatasets,
getDatasetTags,
getDatasets,
+ getViewState,
+ getProjectViewStates,
+ getLayer,
} from '@/api/rest';
-import { Dataset, Project } from '@/types';
+import { Dataset, Project, ViewState } from '@/types';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
import {
useNetworkStore,
@@ -27,6 +31,9 @@ export const useProjectStore = defineStore('project', () => {
const appStore = useAppStore();
const styleStore = useStyleStore();
+ const route = useRoute();
+ const router = useRouter();
+
const loadingProjects = ref(true);
const availableProjects = ref([]);
const currentProject = ref();
@@ -35,32 +42,184 @@ export const useProjectStore = defineStore('project', () => {
const allDatasets = ref();
const availableDatasets = ref();
const availableDatasetTags = ref([]);
+ const availableViewStates = ref([]);
+ const currentViewState = ref();
+ const currentViewStateLoaded = ref(false);
- function fetchProjectDatasets() {
+ async function fetchProjectDatasets() {
if (!currentProject.value) { return; }
loadingDatasets.value = true;
- getProjectDatasets(currentProject.value.id).then(async (datasets) => {
- availableDatasets.value = datasets;
- loadingDatasets.value = false;
- });
+ availableDatasets.value = await getProjectDatasets(currentProject.value.id)
+ loadingDatasets.value = false;
+ }
+
+ async function fetchAvailableDatasetTags() {
+ availableDatasetTags.value = await getDatasetTags()
+ }
+
+ async function fetchProjectViewStates() {
+ if (!currentProject.value) { return; }
+ availableViewStates.value = await getProjectViewStates(currentProject.value.id)
+ }
+
+ function getCurrentViewState(): ViewState | undefined {
+ if (!currentProject.value) return undefined;
+ const mapPosition = mapStore.getCurrentMapPosition();
+ // use proportions instead of coordinates
+ // so that the view state looks good with other window sizes
+ const panelArrangement = panelStore.panelArrangement.map((panelConfig => {
+ const copyConfig = { ...panelConfig }
+ delete copyConfig.element
+ if (copyConfig.position) {
+ copyConfig.position = {
+ x: copyConfig.position.x / window.innerWidth,
+ y: copyConfig.position.y / window.innerHeight,
+ }
+ }
+ return copyConfig
+ }))
+ const includeLayers = layerStore.selectedLayers.filter((layer) => layer.visible)
+ const styleKeysToCurrentFrames = Object.fromEntries(
+ includeLayers.map((layer) => [mapStore.uniqueLayerIdFromLayer(layer), layer.current_frame_index])
+ )
+ const viewState: ViewState = {
+ project: currentProject.value.id,
+ current_analysis_type: analysisStore.currentAnalysisType?.db_value,
+ current_result: analysisStore.currentResult?.id,
+ current_chart: analysisStore.currentChart?.id,
+ current_basemap: mapStore.currentBasemap?.id,
+ current_network: networkStore.currentNetwork?.id,
+ selected_layers: includeLayers.map((layer) => layer.id),
+ selected_layer_current_frames: styleKeysToCurrentFrames,
+ selected_layer_order: Object.keys(styleKeysToCurrentFrames),
+ selected_layer_styles: Object.fromEntries(
+ Object.entries(styleStore.selectedLayerStyles).filter(([styleKey, _]) => {
+ return Object.keys(styleKeysToCurrentFrames).includes(styleKey)
+ })
+ ),
+ left_sidebar_open: appStore.openSidebars.includes('left'),
+ right_sidebar_open: appStore.openSidebars.includes('right'),
+ panel_arrangement: panelArrangement,
+ theme: appStore.theme,
+ map_center: mapPosition.center,
+ map_zoom: Math.round(mapPosition.zoom),
+ }
+ return viewState;
+ }
+
+ function navigateToViewState(viewState: ViewState) {
+ currentViewStateLoaded.value = false;
+ router.push(`/view/${viewState.id}`);
+ }
+
+ watch(() => route?.fullPath, loadViewStateFromURL);
+ async function loadViewStateFromURL() {
+ if (!route.path.includes('/view/')) {
+ currentViewState.value = undefined;
+ return;
+ };
+ const viewStateId = parseInt(route.path.split('/view/')[1]);
+ const viewState = await getViewState(viewStateId)
+ if (viewState) {
+ currentViewState.value = viewState;
+ // Set some state attrs that don't require the project to be loaded first
+ panelStore.panelArrangement = viewState.panel_arrangement.map((panelConfig) => {
+ if (panelConfig.position) {
+ panelConfig.position = {
+ x: panelConfig.position.x * window.innerWidth,
+ y: panelConfig.position.y * window.innerHeight,
+ }
+ }
+ return panelConfig;
+ });
+ appStore.openSidebars = []
+ if (viewState.left_sidebar_open) appStore.openSidebars.push('left');
+ if (viewState.right_sidebar_open) appStore.openSidebars.push('right');
+
+ const selectedProject = availableProjects.value.find((p) => p.id === viewState.project);
+ if (currentProject.value?.id !== selectedProject?.id) {
+ currentProject.value = selectedProject;
+ // Remaining state attrs that depend on project loading will be set by the currentProject watcher
+ } else {
+ if (mapStore.map) mapStore.clearMapLayers();
+ finishLoadingViewState();
+ }
+ }
}
+ async function finishLoadingViewState() {
+ const viewState = currentViewState.value;
+ if (viewState && !currentViewStateLoaded.value && mapStore.map) {
+ appStore.theme = viewState.theme;
+ analysisStore.currentAnalysisType = analysisStore.availableAnalysisTypes?.find((a) => a.db_value === viewState.current_analysis_type);
+ if (analysisStore.currentAnalysisType && currentProject.value) {
+ await analysisStore.initResults(
+ analysisStore.currentAnalysisType.db_value, currentProject.value.id,
+ )
+ analysisStore.currentResult = analysisStore.availableResults?.find((c) => c.id === viewState.current_result);
+ if (analysisStore.currentResult) {
+ analysisStore.currentAnalysisTab = 'old'
+ }
+ }
+ analysisStore.currentChart = analysisStore.availableCharts?.find((c) => c.id === viewState.current_chart);
+ networkStore.currentNetwork = networkStore.availableNetworks.find((n) => n.id === viewState.current_network);
- function fetchAvailableDatasetTags() {
- getDatasetTags().then((tags) => availableDatasetTags.value = tags)
+ // @ts-ignore for "Type instantiation is excessively deep and possibly infinite"
+ mapStore.currentBasemap = mapStore.availableBasemaps?.find((b) => b.id === viewState.current_basemap);
+ mapStore.setMapPosition(viewState.map_center as [number, number], viewState.map_zoom)
+
+ // Add layers with copy ids from selected_layer_styles
+ layerStore.selectedLayers = []
+ await Promise.all(Object.keys(viewState.selected_layer_styles).map(async (styleKey) => {
+ // Parse the style key to get layer id and copy_id
+ const [layerIdStr, copyIdStr] = styleKey.split('.');
+ const layerId = parseInt(layerIdStr);
+ const copyId = parseInt(copyIdStr);
+ const layer = await getLayer(layerId);
+ await layerStore.addLayer(layer, copyId);
+ }))
+ // Ensure correct layer order
+ layerStore.selectedLayers = layerStore.selectedLayers.sort((layer1, layer2) => {
+ const key1 = mapStore.uniqueLayerIdFromLayer(layer1);
+ const key2 = mapStore.uniqueLayerIdFromLayer(layer2);
+ return viewState.selected_layer_order.indexOf(key1) - viewState.selected_layer_order.indexOf(key2)
+ })
+ // Ensure correct current frames
+ layerStore.selectedLayers = layerStore.selectedLayers.map((layer) => {
+ const styleKey = mapStore.uniqueLayerIdFromLayer(layer);
+ if (viewState.selected_layer_current_frames[styleKey]) {
+ layer.current_frame_index = viewState.selected_layer_current_frames[styleKey];
+ }
+ return layer;
+ })
+ styleStore.selectedLayerStyles = viewState.selected_layer_styles;
+ currentViewStateLoaded.value = true;
+ }
}
- watch(currentProject, () => {
+ watch(currentProject, async () => {
clearProjectState();
- mapStore.setMapCenter(currentProject.value);
+
+ if (currentViewState.value) {
+ mapStore.setMapPosition(
+ currentViewState.value.map_center as [number, number],
+ currentViewState.value.map_zoom,
+ true,
+ )
+ } else {
+ mapStore.resetMapPosition(currentProject.value);
+ }
+
mapStore.clearMapLayers();
styleStore.fetchColormaps();
if (currentProject.value) {
- fetchProjectDatasets();
- analysisStore.initCharts(currentProject.value.id);
- analysisStore.initAnalysisTypes(currentProject.value.id);
- networkStore.initNetworks(currentProject.value.id);
+ await fetchProjectDatasets();
+ await fetchProjectViewStates();
+ await analysisStore.initCharts(currentProject.value.id);
+ await analysisStore.initAnalysisTypes(currentProject.value.id);
+ await networkStore.initNetworks(currentProject.value.id);
+ finishLoadingViewState();
}
});
@@ -91,19 +250,17 @@ export const useProjectStore = defineStore('project', () => {
analysisStore.currentAnalysisType = undefined;
}
- function refreshAllDatasets() {
- getDatasets().then(async (datasets) => {
- allDatasets.value = datasets
- })
+ async function refreshAllDatasets() {
+ loadingDatasets.value = true;
+ allDatasets.value = await getDatasets()
+ loadingDatasets.value = false;
}
watch(projectConfigMode, loadProjects);
- function loadProjects() {
+ async function loadProjects() {
clearState();
- getProjects().then((data) => {
- availableProjects.value = data;
- loadingProjects.value = false;
- });
+ availableProjects.value = await getProjects()
+ loadingProjects.value = false;
}
return {
@@ -115,8 +272,15 @@ export const useProjectStore = defineStore('project', () => {
allDatasets,
availableDatasets,
availableDatasetTags,
+ availableViewStates,
+ currentViewState,
+ currentViewStateLoaded,
fetchProjectDatasets,
fetchAvailableDatasetTags,
+ fetchProjectViewStates,
+ getCurrentViewState,
+ navigateToViewState,
+ loadViewStateFromURL,
clearState,
clearProjectState,
refreshAllDatasets,
diff --git a/web/src/types.ts b/web/src/types.ts
index eb8517c3..2cde8b3c 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -18,7 +18,7 @@ export interface Basemap {
export interface Dataset {
id: number;
name?: string;
- description: string;
+ description?: string;
category?: string;
tags?: string[];
processing?: boolean;
@@ -449,3 +449,25 @@ export interface FileItem {
index: number;
metadata: Record;
}
+
+export interface ViewState {
+ id?: number;
+ name?: string;
+ project: number;
+ thumbnail?: string;
+ current_analysis_type: string | undefined,
+ current_result: number | undefined,
+ current_chart: number | undefined,
+ current_basemap: number | undefined,
+ current_network: number | undefined,
+ selected_layers: (number | undefined)[],
+ selected_layer_current_frames: Record,
+ selected_layer_order: string[],
+ selected_layer_styles: Record,
+ left_sidebar_open: boolean,
+ right_sidebar_open: boolean,
+ panel_arrangement: FloatingPanelConfig[],
+ theme: "dark" | "light",
+ map_center: number[],
+ map_zoom: number,
+}