From 4ef3cbd6f15f691487b2acaffd2ef606a6d4e902 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 13:35:37 -0500 Subject: [PATCH 01/35] feat: create View model and migration --- uvdat/core/admin.py | 6 ++ uvdat/core/migrations/0022_views.py | 92 +++++++++++++++++++++++++++++ uvdat/core/models/__init__.py | 2 + uvdat/core/models/view.py | 57 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 uvdat/core/migrations/0022_views.py create mode 100644 uvdat/core/models/view.py diff --git a/uvdat/core/admin.py b/uvdat/core/admin.py index 2e6b6341..81c1fc32 100644 --- a/uvdat/core/admin.py +++ b/uvdat/core/admin.py @@ -25,6 +25,7 @@ TaskResult, VectorData, VectorFeature, + View, ) @@ -140,3 +141,8 @@ class NetworkNodeAdmin(admin.ModelAdmin): @admin.register(TaskResult) class TaskResultAdmin(admin.ModelAdmin): list_display = ["id", "task_type", "inputs"] + + +@admin.register(View) +class ViewAdmin(admin.ModelAdmin): + list_display = ["id", "name", "project"] diff --git a/uvdat/core/migrations/0022_views.py b/uvdat/core/migrations/0022_views.py new file mode 100644 index 00000000..babc2fe3 --- /dev/null +++ b/uvdat/core/migrations/0022_views.py @@ -0,0 +1,92 @@ +# Generated by Django 5.2.9 on 2026-02-25 18:34 +from __future__ import annotations + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion +import s3_file_field.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0021_non_null_text_fields"), + ] + + operations = [ + migrations.CreateModel( + name="View", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=255)), + ("thumbnail", s3_file_field.fields.S3FileField()), + ("theme", models.CharField(default="light", max_length=10)), + ("current_analysis_type", models.CharField(blank=True, max_length=25)), + ("left_sidebar_open", models.BooleanField(default=False)), + ("right_sidebar_open", models.BooleanField(default=False)), + ("map_zoom", models.IntegerField(null=True)), + ( + "map_center", + django.contrib.gis.db.models.fields.PointField(null=True, srid=4326), + ), + ("panel_arrangement", models.JSONField(blank=True, null=True)), + ("selected_layer_order", models.JSONField(blank=True, null=True)), + ("selected_layer_styles", models.JSONField(blank=True, null=True)), + ( + "current_basemap", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="core.basemap", + ), + ), + ( + "current_chart", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="core.chart", + ), + ), + ( + "current_network", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="core.network", + ), + ), + ( + "current_result", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="core.taskresult", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to="core.project", + ), + ), + ("selected_layers", models.ManyToManyField(related_name="views", to="core.layer")), + ], + options={ + "constraints": [ + models.UniqueConstraint(fields=("project", "name"), name="uniqueviewname") + ], + }, + ), + ] diff --git a/uvdat/core/models/__init__.py b/uvdat/core/models/__init__.py index 782c14b5..d9c005aa 100644 --- a/uvdat/core/models/__init__.py +++ b/uvdat/core/models/__init__.py @@ -19,6 +19,7 @@ SizeRangeConfig, ) from .task_result import TaskResult +from .view import View __all__ = [ "Basemap", @@ -44,4 +45,5 @@ "TaskResult", "VectorData", "VectorFeature", + "View", ] diff --git a/uvdat/core/models/view.py b/uvdat/core/models/view.py new file mode 100644 index 00000000..13e20590 --- /dev/null +++ b/uvdat/core/models/view.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from django.contrib.gis.db import models as geo_models +from django.db import models +from s3_file_field import S3FileField + +from .basemap import Basemap +from .chart import Chart +from .layer import Layer +from .networks import Network +from .project import Project +from .querysets import ProjectQuerySet +from .task_result import TaskResult + + +class View(models.Model): + name = models.CharField(max_length=255) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="views", null=True) + thumbnail = S3FileField() + + theme = models.CharField(max_length=10, default="light") + current_analysis_type = models.CharField( + max_length=25, + blank=True, # max length matches TaskResult.task_type + ) + current_result = models.ForeignKey( + TaskResult, on_delete=models.CASCADE, related_name="views", null=True + ) + current_basemap = models.ForeignKey( + Basemap, on_delete=models.CASCADE, related_name="views", null=True + ) + current_chart = models.ForeignKey( + Chart, on_delete=models.CASCADE, related_name="views", null=True + ) + current_network = models.ForeignKey( + Network, on_delete=models.CASCADE, related_name="views", null=True + ) + left_sidebar_open = models.BooleanField(default=False) + right_sidebar_open = models.BooleanField(default=False) + map_zoom = models.IntegerField(null=True) + map_center = geo_models.PointField(null=True) + panel_arrangement = models.JSONField(blank=True, null=True) + selected_layers = models.ManyToManyField(Layer, related_name="views") + selected_layer_order = models.JSONField(blank=True, null=True) + selected_layer_styles = models.JSONField(blank=True, null=True) + + project_filter_path = "project" + objects = ProjectQuerySet.as_manager() + + class Meta: + constraints = [ + # We enforce name uniqueness across projects + models.UniqueConstraint(name="uniqueviewname", fields=["project", "name"]) + ] + + def __str__(self): + return f"{self.name} ({self.id})" From 6c346701e10f7ff854098f446ab9396c2769bb83 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 13:42:01 -0500 Subject: [PATCH 02/35] feat: Add rest endpoints for View model --- uvdat/core/rest/__init__.py | 2 ++ uvdat/core/rest/serializers.py | 21 +++++++++++++++++++++ uvdat/core/rest/view.py | 13 +++++++++++++ uvdat/urls.py | 2 ++ 4 files changed, 38 insertions(+) create mode 100644 uvdat/core/rest/view.py diff --git a/uvdat/core/rest/__init__.py b/uvdat/core/rest/__init__.py index 55621c50..f4f0bcba 100644 --- a/uvdat/core/rest/__init__.py +++ b/uvdat/core/rest/__init__.py @@ -12,6 +12,7 @@ from .project import ProjectViewSet from .regions import RegionViewSet from .user import UserViewSet +from .view import ViewViewSet __all__ = [ "AnalyticsViewSet", @@ -29,4 +30,5 @@ "RegionViewSet", "UserViewSet", "VectorDataViewSet", + "ViewViewSet", ] diff --git a/uvdat/core/rest/serializers.py b/uvdat/core/rest/serializers.py index d8be2e5c..3a93e830 100644 --- a/uvdat/core/rest/serializers.py +++ b/uvdat/core/rest/serializers.py @@ -23,6 +23,7 @@ Region, TaskResult, VectorData, + View, ) @@ -281,3 +282,23 @@ def get_name(self, obj): class Meta: model = TaskResult fields = "__all__" + + +class ViewSerializer(serializers.ModelSerializer): + map_center = serializers.SerializerMethodField("get_center") + + def get_center(self, obj): + # Web client expects Lon, Lat + if obj.map_center: + return [obj.map_center.y, obj.map_center.x] + + def to_internal_value(self, data): + center = data.get("map_center") + data = super().to_internal_value(data) + if isinstance(center, list): + data["map_center"] = Point(center[1], center[0]) + return data + + class Meta: + model = View + fields = "__all__" diff --git a/uvdat/core/rest/view.py b/uvdat/core/rest/view.py new file mode 100644 index 00000000..8edd0dfe --- /dev/null +++ b/uvdat/core/rest/view.py @@ -0,0 +1,13 @@ +from rest_framework.viewsets import ModelViewSet + +from uvdat.core.models import View +from uvdat.core.rest.access_control import GuardianFilter, GuardianPermission +from uvdat.core.rest.serializers import ViewSerializer + + +class ViewViewSet(ModelViewSet): + queryset = View.objects.all() + serializer_class = ViewSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' diff --git a/uvdat/urls.py b/uvdat/urls.py index f2ffafa7..df6a7aa0 100644 --- a/uvdat/urls.py +++ b/uvdat/urls.py @@ -25,6 +25,7 @@ RegionViewSet, UserViewSet, VectorDataViewSet, + ViewViewSet, ) router = routers.SimpleRouter() @@ -50,6 +51,7 @@ router.register(r"networks", NetworkViewSet, basename="networks") router.register(r"basemaps", BasemapViewSet, basename="basemaps") router.register(r"analytics", AnalyticsViewSet, basename="analytics") +router.register(r"views", ViewViewSet, basename="views") urlpatterns = [ From 7e9acfc517420be25ad4ee8e467fa5ca800886b5 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 13:52:23 -0500 Subject: [PATCH 03/35] build: Add vue-router to web client --- web/components.d.ts | 2 + web/package-lock.json | 352 +++++++++++++++++++++++++++++++++++++++--- web/package.json | 1 + web/src/main.ts | 14 +- 4 files changed, 347 insertions(+), 22 deletions(-) diff --git a/web/components.d.ts b/web/components.d.ts index 94a019a3..c1ea0bac 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -31,6 +31,8 @@ declare module 'vue' { NodeAnimation: typeof import('./src/components/sidebars/NodeAnimation.vue')['default'] ProjectConfig: typeof import('./src/components/projects/ProjectConfig.vue')['default'] RecursiveTable: typeof import('./src/components/RecursiveTable.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] SideBars: typeof import('./src/components/sidebars/SideBars.vue')['default'] SliderNumericInput: typeof import('./src/components/SliderNumericInput.vue')['default'] ToggleCompareMap: typeof import('./src/components/map/ToggleCompareMap.vue')['default'] diff --git a/web/package-lock.json b/web/package-lock.json index f970b29c..39183f24 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "vue": "^3.2.13", "vue-chartjs": "^5.2.0", "vue-maplibre-compare": "^1.0.26", + "vue-router": "^5.0.3", "vuedraggable": "^4.1.0", "vuetify": "^3.8.0" }, @@ -51,6 +52,22 @@ "vite-plugin-vuetify": "^2.1.1" } }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -70,12 +87,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -85,9 +102,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -4215,6 +4232,61 @@ "vue": "^3.2.25" } }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue-macros/common/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue-macros/common/node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -4519,6 +4591,38 @@ "util": "^0.12.5" } }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5076,7 +5180,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "dev": true, "license": "MIT" }, "node_modules/console-browserify": { @@ -5965,7 +6068,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -6971,6 +7073,18 @@ "node": ">= 10.16.0" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7050,6 +7164,18 @@ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonpath-plus": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", @@ -7150,7 +7276,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "dev": true, "license": "MIT", "dependencies": { "mlly": "^1.7.4", @@ -7220,6 +7345,21 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/maplibre-gl": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", @@ -7421,7 +7561,6 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.15.0", @@ -7434,14 +7573,12 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, "license": "MIT" }, "node_modules/mlly/node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -7456,6 +7593,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, "node_modules/murmurhash-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", @@ -7871,7 +8014,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pbf": { @@ -7989,7 +8131,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -8227,7 +8368,6 @@ "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "dev": true, "funding": [ { "type": "individual", @@ -8604,6 +8744,12 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -9057,7 +9203,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -9074,7 +9219,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9092,7 +9236,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9297,7 +9440,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, "license": "MIT" }, "node_modules/unplugin": { @@ -9842,6 +9984,160 @@ "vue": "^3.0.0" } }, + "node_modules/vue-router": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", + "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz", + "integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.6" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.6.tgz", + "integrity": "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.6", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^2.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz", + "integrity": "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/vue-router/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vue-router/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vue-router/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vue-router/node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", @@ -9897,7 +10193,6 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, "license": "MIT" }, "node_modules/which": { @@ -9971,6 +10266,21 @@ "node": ">=0.4" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index c073ad1d..ec0199c1 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "vue": "^3.2.13", "vue-chartjs": "^5.2.0", "vue-maplibre-compare": "^1.0.26", + "vue-router": "^5.0.3", "vuedraggable": "^4.1.0", "vuetify": "^3.8.0" }, diff --git a/web/src/main.ts b/web/src/main.ts index 749069da..006f8f99 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,6 +33,17 @@ const vuetify = createVuetify({ app.use(vuetify) app.use(JsonEditorVue); +// Initialize router +const router = createRouter({ + history: createWebHistory(), + routes: [{ + path: '/', + name: 'Home', + component: App + },], +}); +app.use(router); + // Finally, mount the app restoreLogin().then(() => { app.mount("#app"); From 453de9194cfe0e905bb55a86bfe178de58e8631f Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 13:58:31 -0500 Subject: [PATCH 04/35] feat: Add view type and rest functions --- web/src/api/auth.js | 6 ++++++ web/src/api/rest.ts | 20 ++++++++++++++++++++ web/src/types.ts | 21 +++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/web/src/api/auth.js b/web/src/api/auth.js index d196968d..14581935 100644 --- a/web/src/api/auth.js +++ b/web/src/api/auth.js @@ -56,6 +56,12 @@ apiClient.interceptors.response.use( }, (error) => { const appStore = useAppStore(); + + if (error.config?.errorMsg) { + appStore.currentError = error.config?.errorMsg + return { data: undefined }; + } + if (error.response?.status === 500) { appStore.currentError = "Server error; see server logs for details."; } else if (error.response?.status === 404) { diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 28a2a0b6..5dddc473 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -21,6 +21,7 @@ import { TaskResult, Colormap, Basemap, + View, } from "@/types"; export async function getUsers(): Promise { @@ -260,3 +261,22 @@ export async function updateColormap(colormap: Colormap): Promise { export async function deleteColormap(colormapId: number): Promise { return (await apiClient.delete(`colormaps/${colormapId}/`)).data; } + +export async function getView(viewId: number): Promise { + return (await apiClient.get( + `views/${viewId}`, + {errorMsg: 'Could not load view. Ensure that view ID in URL is correct.'}, + )).data; +} + +export async function getProjectViews(projectId: number): Promise { + return (await apiClient.get(`views/?project=${projectId}`)).data.results; +} + +export async function createView(view: View): Promise { + return (await apiClient.post('views/', view)).data; +} + +export async function deleteView(view: View): Promise { + return (await apiClient.delete(`views/${view.id}/`,)).data; +} diff --git a/web/src/types.ts b/web/src/types.ts index eb8517c3..332035c8 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -449,3 +449,24 @@ export interface FileItem { index: number; metadata: Record; } + +export interface View { + 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_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, +} From d32a9d1234387dd83dd54848786b261e0e0993e5 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 14:09:42 -0500 Subject: [PATCH 05/35] chore: refactor set/reset map position and reformat some component files --- web/src/components/map/LegacyMap.vue | 2 +- web/src/components/map/ToggleCompareMap.vue | 205 ++++++------- web/src/components/projects/ProjectConfig.vue | 280 +++++------------- web/src/store/map.ts | 27 +- web/src/store/project.ts | 2 +- 5 files changed, 190 insertions(+), 326 deletions(-) diff --git a/web/src/components/map/LegacyMap.vue b/web/src/components/map/LegacyMap.vue index b1e664e1..9484e41f 100644 --- a/web/src/components/map/LegacyMap.vue +++ b/web/src/components/map/LegacyMap.vue @@ -122,7 +122,7 @@ function createMapControls() { onMounted(() => { mapStore.fetchAvailableBasemaps().then(() => { createMap(); - mapStore.setMapCenter(undefined, true); + mapStore.resetMapPosition(undefined, true); }) }); diff --git a/web/src/components/map/ToggleCompareMap.vue b/web/src/components/map/ToggleCompareMap.vue index e55f7004..aaf040f0 100644 --- a/web/src/components/map/ToggleCompareMap.vue +++ b/web/src/components/map/ToggleCompareMap.vue @@ -10,7 +10,6 @@ import type { StyleSpecification, Map, ResourceType } from "maplibre-gl"; import { useTheme } from 'vuetify'; import { Protocol } from "pmtiles"; import { storeToRefs } from "pinia"; -import { map } from "lodash"; const ATTRIBUTION = [ "© MapLibre", @@ -71,45 +70,45 @@ const handleMapReady = async (newMap: Map, mapId: 'A' | 'B') => { if (mapStore.availableBasemaps.length === 0) { await mapStore.fetchAvailableBasemaps(); } - newMap.addControl(attributionControl); - newMap.on('error', (response) => { - // AbortErrors are raised when updating style of raster layers; ignore these - if (response.error.message !== 'AbortError') console.error(response.error) - }); - /** - * This is called on every click, and technically hides the tooltip on every click. - * However, if a feature layer is clicked, that event is fired after this one, and the - * tooltip is re-enabled and rendered with the desired contents. The net result is that - * this only has a real effect when the base map is clicked, as that means that no other - * feature layer can "catch" the event, and the tooltip stays hidden. - */ - newMap.on("click", (e) => { - // check if click is in the compare map and it's enabled - if (e.point.x > compareStore.sliderEnd.position && isComparing.value) { - return; // let the compare map handle this click - } - mapStore.clickedFeature = undefined; - }); - if (mapId === 'A') { - newMap.setStyle(mapStore.currentBasemap?.style as StyleSpecification); - mapStore.map = newMap; - mapStore.setMapCenter(undefined, true); - } else if (mapId === 'B') { - mapStore.compareMap = newMap; - newMap.on("click", () => {mapStore.compareClickedFeature = undefined }); - createMapControls(newMap, 'B'); - // the B map style is compared implicitly we need to add click handlers for features here as well - if (mapBStyle.value && mapBStyle.value?.sources) - Object.entries(mapBStyle.value.sources).forEach(([key,source]) => { - if (source.type === 'vector') { - mapStore.setupVectorLayerClickHandlers(newMap, key, mapStore.handleCompareLayerClick); - } - }); + newMap.addControl(attributionControl); + newMap.on('error', (response) => { + // AbortErrors are raised when updating style of raster layers; ignore these + if (response.error.message !== 'AbortError') console.error(response.error) + }); + /** + * This is called on every click, and technically hides the tooltip on every click. + * However, if a feature layer is clicked, that event is fired after this one, and the + * tooltip is re-enabled and rendered with the desired contents. The net result is that + * this only has a real effect when the base map is clicked, as that means that no other + * feature layer can "catch" the event, and the tooltip stays hidden. + */ + newMap.on("click", (e) => { + // check if click is in the compare map and it's enabled + if (e.point.x > compareStore.sliderEnd.position && isComparing.value) { + return; // let the compare map handle this click } - createMapControls(newMap); - newMap.once('idle', () => { - layerStore.updateLayersShown(); - }); + mapStore.clickedFeature = undefined; + }); + if (mapId === 'A') { + newMap.setStyle(mapStore.currentBasemap?.style as StyleSpecification); + mapStore.map = newMap; + mapStore.resetMapPosition(undefined, true); + } else if (mapId === 'B') { + mapStore.compareMap = newMap; + newMap.on("click", () => { mapStore.compareClickedFeature = undefined }); + createMapControls(newMap, 'B'); + // the B map style is compared implicitly we need to add click handlers for features here as well + if (mapBStyle.value && mapBStyle.value?.sources) + Object.entries(mapBStyle.value.sources).forEach(([key, source]) => { + if (source.type === 'vector') { + mapStore.setupVectorLayerClickHandlers(newMap, key, mapStore.handleCompareLayerClick); + } + }); + } + createMapControls(newMap); + newMap.once('idle', () => { + layerStore.updateLayersShown(); + }); } function createMapControls(map: Map, mapType: 'A' | 'B' = 'A') { @@ -127,7 +126,7 @@ function createMapControls(map: Map, mapType: 'A' | 'B' = 'A') { }); // Link overlay ref to dom, allowing for modification elsewhere - popup.setDOMContent(currentTooltip.value); + popup.setDOMContent(currentTooltip.value); if (mapType === 'A') { // Set store value mapStore.tooltipOverlay = popup; @@ -151,43 +150,44 @@ watch(() => appStore.openSidebars, () => { }); const transformRequest = (url: string, _resourceType?: ResourceType) => { - // Only add auth headers to our own tile requests - if (url.includes(import.meta.env.VITE_APP_API_ROOT)) { - return { - url, - headers: oauthClient?.authHeaders, - }; - } - return { url }; + // Only add auth headers to our own tile requests + if (url.includes(import.meta.env.VITE_APP_API_ROOT)) { + return { + url, + headers: oauthClient?.authHeaders, + }; + } + return { url }; } +// @ts-ignore for "Type instantiation is excessively deep and possibly infinite" const mapStyleA: Ref = ref(mapStore.currentBasemap?.style as StyleSpecification); watch(isComparing, (newVal) => { - if (!newVal && mapStore.map) { - mapStore.map.jumpTo({ - center: mapStats.value?.center, - zoom: mapStats.value?.zoom, - bearing: mapStats.value?.bearing, - pitch: mapStats.value?.pitch, - }); - mapStore.compareMap = undefined; - } else if (newVal && mapStore.map) { - mapStyleA.value = mapStore.map.getStyle(); - compareStore.updateSlider({percentage: 50, position: window.innerWidth * 0.5}); - } + if (!newVal && mapStore.map) { + mapStore.map.jumpTo({ + center: mapStats.value?.center, + zoom: mapStats.value?.zoom, + bearing: mapStats.value?.bearing, + pitch: mapStats.value?.pitch, + }); + mapStore.compareMap = undefined; + } else if (newVal && mapStore.map) { + mapStyleA.value = mapStore.map.getStyle(); + compareStore.updateSlider({ percentage: 50, position: window.innerWidth * 0.5 }); + } }); watch(mapAStyle, (newStyle) => { - if (isComparing.value && mapStore.map) { - mapStyleA.value = newStyle as StyleSpecification; - } -}, { deep: true}); + if (isComparing.value && mapStore.map) { + mapStyleA.value = newStyle as StyleSpecification; + } +}, { deep: true }); // Updating the basemap for either map should update both maps // Maps need to load their style if is a direct url string before updating mapStyleA // Then if comparing we need to do another idle until mapB is updated and then set the order const updateBasemap = () => { - const map = mapStore.map; + const map = mapStore.map; if (map && mapStore.currentBasemap) { const visible = mapStore.currentBasemap.id !== undefined mapStore.setBasemapVisibility(visible); @@ -205,65 +205,50 @@ const updateBasemap = () => { compareStore.mapLayersB = compareStore.updateCompareLayersList('B'); }); } - } - }); + } + }); } } } } watch(() => mapStore.currentBasemap, () => { - updateBasemap(); + updateBasemap(); }); const swiperColor = computed(() => { - return { - swiper: theme.global.current.value.colors.primary, - arrow: theme.global.current.value.colors['button-text'], - }; + return { + swiper: theme.global.current.value.colors.primary, + arrow: theme.global.current.value.colors['button-text'], + }; }); \ No newline at end of file + diff --git a/web/src/components/projects/ProjectConfig.vue b/web/src/components/projects/ProjectConfig.vue index 4c60534d..7ecf43f2 100644 --- a/web/src/components/projects/ProjectConfig.vue +++ b/web/src/components/projects/ProjectConfig.vue @@ -124,7 +124,7 @@ function saveProjectMapLocation(project: Project | undefined) { projectStore.currentProject.default_map_center = project.default_map_center projectStore.currentProject.default_map_zoom = project.default_map_zoom } - mapStore.setMapCenter(project); + mapStore.resetMapPosition(project); saving.value = "done"; setTimeout(() => { saving.value = undefined; @@ -208,7 +208,7 @@ function handleEditFocus(focused: boolean) { } } -function datasetUploaded(result: {dataset: Dataset, conversionTask: TaskResult}) { +function datasetUploaded(result: { dataset: Dataset, conversionTask: TaskResult }) { projectStore.refreshAllDatasets() refreshProjectDatasets(null) } @@ -242,48 +242,26 @@ watch(() => projectStore.projectConfigMode, () => { From d551fdaadcb3c7f49fc1674f024d3b5442b13525 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:33:12 -0500 Subject: [PATCH 10/35] fix: Allow no selected layers in view --- uvdat/core/migrations/0022_views.py | 7 +++++-- uvdat/core/models/view.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/uvdat/core/migrations/0022_views.py b/uvdat/core/migrations/0022_views.py index babc2fe3..72dc73f1 100644 --- a/uvdat/core/migrations/0022_views.py +++ b/uvdat/core/migrations/0022_views.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.9 on 2026-02-25 18:34 +# Generated by Django 5.2.9 on 2026-02-25 20:30 from __future__ import annotations import django.contrib.gis.db.models.fields @@ -81,7 +81,10 @@ class Migration(migrations.Migration): to="core.project", ), ), - ("selected_layers", models.ManyToManyField(related_name="views", to="core.layer")), + ( + "selected_layers", + models.ManyToManyField(blank=True, related_name="views", to="core.layer"), + ), ], options={ "constraints": [ diff --git a/uvdat/core/models/view.py b/uvdat/core/models/view.py index 13e20590..ac2f108b 100644 --- a/uvdat/core/models/view.py +++ b/uvdat/core/models/view.py @@ -40,7 +40,7 @@ class View(models.Model): map_zoom = models.IntegerField(null=True) map_center = geo_models.PointField(null=True) panel_arrangement = models.JSONField(blank=True, null=True) - selected_layers = models.ManyToManyField(Layer, related_name="views") + selected_layers = models.ManyToManyField(Layer, related_name="views", blank=True) selected_layer_order = models.JSONField(blank=True, null=True) selected_layer_styles = models.JSONField(blank=True, null=True) From 65c58f62f459a93eab6c324210cd921f64dbac88 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:33:44 -0500 Subject: [PATCH 11/35] fix: Add explicit return None statement --- uvdat/core/rest/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/uvdat/core/rest/serializers.py b/uvdat/core/rest/serializers.py index 3a93e830..d2ed84f0 100644 --- a/uvdat/core/rest/serializers.py +++ b/uvdat/core/rest/serializers.py @@ -291,6 +291,7 @@ def get_center(self, obj): # Web client expects Lon, Lat if obj.map_center: return [obj.map_center.y, obj.map_center.x] + return None def to_internal_value(self, data): center = data.get("map_center") From 76707d203329dce199516bdabd77f4d8cef54ab7 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:34:06 -0500 Subject: [PATCH 12/35] style: Reformat rest/view.py --- uvdat/core/rest/view.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/uvdat/core/rest/view.py b/uvdat/core/rest/view.py index 8edd0dfe..1bda2181 100644 --- a/uvdat/core/rest/view.py +++ b/uvdat/core/rest/view.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from rest_framework.viewsets import ModelViewSet from uvdat.core.models import View @@ -10,4 +12,4 @@ class ViewViewSet(ModelViewSet): serializer_class = ViewSerializer permission_classes = [GuardianPermission] filter_backends = [GuardianFilter] - lookup_field = 'id' + lookup_field = "id" From 431cc8c0ed2275b9f46a648e9498156764375527 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:35:14 -0500 Subject: [PATCH 13/35] fix: Use proportional panel positions --- web/src/store/project.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/web/src/store/project.ts b/web/src/store/project.ts index 7e8f13d6..e7a05373 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -71,9 +71,17 @@ export const useProjectStore = defineStore('project', () => { function getCurrentView(): View | undefined { if (!currentProject.value) return undefined; const mapPosition = mapStore.getCurrentMapPosition(); + // use proportions instead of coordinates + // so that the view 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 view: View = { @@ -115,7 +123,15 @@ export const useProjectStore = defineStore('project', () => { if (view) { currentView.value = view; // Set some state attrs that don't require the project to be loaded first - panelStore.panelArrangement = view.panel_arrangement; + panelStore.panelArrangement = view.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 (view.left_sidebar_open) appStore.openSidebars.push('left'); if (view.right_sidebar_open) appStore.openSidebars.push('right'); From d2586126d4ed6cba7ca5d3a3195835cab3bf95cc Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:40:35 -0500 Subject: [PATCH 14/35] fix: Only load view once map is initialized --- web/src/App.vue | 1 - web/src/components/map/ToggleCompareMap.vue | 1 + web/src/store/map.ts | 10 +++++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/App.vue b/web/src/App.vue index 5d3218db..dd8f2d52 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -17,7 +17,6 @@ function onReady() { if (appStore.currentUser) { projectStore.clearState(); projectStore.loadProjects(); - projectStore.loadViewFromURL(); conversionStore.createWebSocket(); } } diff --git a/web/src/components/map/ToggleCompareMap.vue b/web/src/components/map/ToggleCompareMap.vue index aaf040f0..1b31f1db 100644 --- a/web/src/components/map/ToggleCompareMap.vue +++ b/web/src/components/map/ToggleCompareMap.vue @@ -51,6 +51,7 @@ attributionControl.onAdd = (map: Map): HTMLElement => { function setAttributionControlStyle() { const container = attributionControl._container; + if (!container) return; container.style.padding = "3px 8px"; container.style.marginRight = "5px"; container.style.borderRadius = "15px"; diff --git a/web/src/store/map.ts b/web/src/store/map.ts index 38795b58..f937cf66 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 = [ @@ -550,6 +551,13 @@ export const useMapStore = defineStore('map', () => { } } + watch(map, () => { + // Once map is initialized, attempt to load URL view + if (map.value) { + projectStore.loadViewFromURL() + } + }) + return { // Data map, From 8c8b8d8e6d3ed5220d5084900ef8e4f9355562f5 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 15:43:59 -0500 Subject: [PATCH 15/35] fix: Update vue router instantiation --- web/src/main.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 006f8f99..04607748 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -37,7 +37,7 @@ app.use(JsonEditorVue); const router = createRouter({ history: createWebHistory(), routes: [{ - path: '/', + path: '/:pathMatch(.*)*', name: 'Home', component: App },], @@ -45,6 +45,8 @@ const router = createRouter({ app.use(router); // Finally, mount the app -restoreLogin().then(() => { - app.mount("#app"); -}); +router.isReady().then(() => { + restoreLogin().then(() => { + app.mount("#app"); + }); +}) From 43e1cbb8b198df0e8946b4615bf5d5f97d77911b Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 16:22:35 -0500 Subject: [PATCH 16/35] fix: Ensure correct layer ordering when loading view --- web/src/store/project.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/store/project.ts b/web/src/store/project.ts index e7a05373..458aed20 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -168,8 +168,15 @@ export const useProjectStore = defineStore('project', () => { const layerId = parseInt(layerIdStr); const copyId = parseInt(copyIdStr); const layer = await getLayer(layerId); - layerStore.addLayer(layer, copyId); + await layerStore.addLayer(layer, copyId); })) + // Ensure correct layer order + layerStore.selectedLayers = layerStore.selectedLayers.sort((layer1, layer2) => { + const key1 = `${layer1.id}.${layer1.copy_id}`; + const key2 = `${layer2.id}.${layer2.copy_id}`; + return view.selected_layer_order.indexOf(key1) - view.selected_layer_order.indexOf(key2) + }) + layerStore.updateLayersShown() styleStore.selectedLayerStyles = view.selected_layer_styles; currentViewLoaded.value = true; } From 0c1b68eb2867c5a48e122af741d404a1a8470607 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 16:33:25 -0500 Subject: [PATCH 17/35] feat: Save/load current layer frames --- uvdat/core/migrations/0022_views.py | 3 ++- uvdat/core/models/view.py | 1 + web/src/store/project.ts | 19 ++++++++++++++++--- web/src/types.ts | 1 + 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/uvdat/core/migrations/0022_views.py b/uvdat/core/migrations/0022_views.py index 72dc73f1..78c18b73 100644 --- a/uvdat/core/migrations/0022_views.py +++ b/uvdat/core/migrations/0022_views.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.9 on 2026-02-25 20:30 +# Generated by Django 5.2.9 on 2026-02-25 21:27 from __future__ import annotations import django.contrib.gis.db.models.fields @@ -34,6 +34,7 @@ class Migration(migrations.Migration): django.contrib.gis.db.models.fields.PointField(null=True, srid=4326), ), ("panel_arrangement", models.JSONField(blank=True, null=True)), + ("selected_layer_current_frames", models.JSONField(blank=True, null=True)), ("selected_layer_order", models.JSONField(blank=True, null=True)), ("selected_layer_styles", models.JSONField(blank=True, null=True)), ( diff --git a/uvdat/core/models/view.py b/uvdat/core/models/view.py index ac2f108b..5ce304e0 100644 --- a/uvdat/core/models/view.py +++ b/uvdat/core/models/view.py @@ -41,6 +41,7 @@ class View(models.Model): map_center = geo_models.PointField(null=True) panel_arrangement = models.JSONField(blank=True, null=True) selected_layers = models.ManyToManyField(Layer, related_name="views", blank=True) + selected_layer_current_frames = models.JSONField(blank=True, null=True) selected_layer_order = models.JSONField(blank=True, null=True) selected_layer_styles = models.JSONField(blank=True, null=True) diff --git a/web/src/store/project.ts b/web/src/store/project.ts index 458aed20..f3daead6 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -92,6 +92,12 @@ export const useProjectStore = defineStore('project', () => { current_basemap: mapStore.currentBasemap?.id, current_network: networkStore.currentNetwork?.id, selected_layers: layerStore.selectedLayers.map((layer) => layer.id), + selected_layer_current_frames: Object.fromEntries( + layerStore.selectedLayers.map((layer) => { + const styleKey = mapStore.uniqueLayerIdFromLayer(layer); + return [styleKey, layer.current_frame_index] + }) + ), selected_layer_order: layerStore.selectedLayers.map((layer) => { // Includes layer ID and copy ID return mapStore.uniqueLayerIdFromLayer(layer); @@ -172,11 +178,18 @@ export const useProjectStore = defineStore('project', () => { })) // Ensure correct layer order layerStore.selectedLayers = layerStore.selectedLayers.sort((layer1, layer2) => { - const key1 = `${layer1.id}.${layer1.copy_id}`; - const key2 = `${layer2.id}.${layer2.copy_id}`; + const key1 = mapStore.uniqueLayerIdFromLayer(layer1); + const key2 = mapStore.uniqueLayerIdFromLayer(layer2); return view.selected_layer_order.indexOf(key1) - view.selected_layer_order.indexOf(key2) }) - layerStore.updateLayersShown() + // Ensure correct current frames + layerStore.selectedLayers = layerStore.selectedLayers.map((layer) => { + const styleKey = mapStore.uniqueLayerIdFromLayer(layer); + if (view.selected_layer_current_frames[styleKey]) { + layer.current_frame_index = view.selected_layer_current_frames[styleKey]; + } + return layer; + }) styleStore.selectedLayerStyles = view.selected_layer_styles; currentViewLoaded.value = true; } diff --git a/web/src/types.ts b/web/src/types.ts index 0e5a71ae..c4247555 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -461,6 +461,7 @@ export interface View { 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, From 39ff94f144f4f2d6fce12b24beee286f4037e246 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 25 Feb 2026 16:48:46 -0500 Subject: [PATCH 18/35] fix: Ensure analysis result is shown when loading view --- web/src/components/sidebars/AnalyticsPanel.vue | 11 +++++------ web/src/store/analysis.ts | 2 ++ web/src/store/project.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/web/src/components/sidebars/AnalyticsPanel.vue b/web/src/components/sidebars/AnalyticsPanel.vue index 78662166..6be479a7 100644 --- a/web/src/components/sidebars/AnalyticsPanel.vue +++ b/web/src/components/sidebars/AnalyticsPanel.vue @@ -32,7 +32,6 @@ const filteredAnalysisTypes = computed(() => { analysis_type.name.toLowerCase().includes(searchText.value.toLowerCase()) }) }) -const tab = ref(); const newestFirstResults = computed(() => { return analysisStore.availableResults.toSorted((a, b) => { const aCreated = new Date(a.created); @@ -88,7 +87,7 @@ function run() { projectStore.currentProject.id, selectedInputs.value, ).then((result) => { - tab.value = 'old'; + analysisStore.currentAnalysisTab = 'old'; analysisStore.currentResult = result; fetchResults(); }) @@ -202,8 +201,8 @@ watch(() => analysisStore.currentAnalysisType, () => { } }) -watch(tab, () => { - if (tab.value === "old") { +watch(() => analysisStore.currentAnalysisTab, () => { + if (analysisStore.currentAnalysisTab === "old") { fetchResults(); } }); @@ -235,12 +234,12 @@ watch( - + Run New View Existing - + Select inputs diff --git a/web/src/store/analysis.ts b/web/src/store/analysis.ts index 6ec229d1..d3adef4e 100644 --- a/web/src/store/analysis.ts +++ b/web/src/store/analysis.ts @@ -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(); @@ -77,6 +78,7 @@ export const useAnalysisStore = defineStore('analysis', () => { loadingCharts, availableCharts, currentChart, + currentAnalysisTab, loadingAnalysisTypes, availableAnalysisTypes, currentAnalysisType, diff --git a/web/src/store/project.ts b/web/src/store/project.ts index f3daead6..48a437a7 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -158,7 +158,15 @@ export const useProjectStore = defineStore('project', () => { if (view && !currentViewLoaded.value && mapStore.map) { appStore.theme = view.theme; analysisStore.currentAnalysisType = analysisStore.availableAnalysisTypes?.find((a) => a.db_value === view.current_analysis_type); - analysisStore.currentResult = analysisStore.availableResults?.find((c) => c.id === view.current_result); + if (analysisStore.currentAnalysisType && currentProject.value) { + await analysisStore.initResults( + analysisStore.currentAnalysisType.db_value, currentProject.value.id, + ) + analysisStore.currentResult = analysisStore.availableResults?.find((c) => c.id === view.current_result); + if (analysisStore.currentResult) { + analysisStore.currentAnalysisTab = 'old' + } + } analysisStore.currentChart = analysisStore.availableCharts?.find((c) => c.id === view.current_chart); networkStore.currentNetwork = networkStore.availableNetworks.find((n) => n.id === view.current_network); From ad2079eb9fefedaa5285065380c80f129ecae758 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Thu, 26 Feb 2026 10:36:04 -0500 Subject: [PATCH 19/35] fix: Ensure only visible layers are saved in views --- web/src/store/project.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/store/project.ts b/web/src/store/project.ts index 48a437a7..5d827d61 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -84,6 +84,10 @@ export const useProjectStore = defineStore('project', () => { } 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 view: View = { project: currentProject.value.id, current_analysis_type: analysisStore.currentAnalysisType?.db_value, @@ -91,18 +95,14 @@ export const useProjectStore = defineStore('project', () => { current_chart: analysisStore.currentChart?.id, current_basemap: mapStore.currentBasemap?.id, current_network: networkStore.currentNetwork?.id, - selected_layers: layerStore.selectedLayers.map((layer) => layer.id), - selected_layer_current_frames: Object.fromEntries( - layerStore.selectedLayers.map((layer) => { - const styleKey = mapStore.uniqueLayerIdFromLayer(layer); - return [styleKey, layer.current_frame_index] + 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) }) ), - selected_layer_order: layerStore.selectedLayers.map((layer) => { - // Includes layer ID and copy ID - return mapStore.uniqueLayerIdFromLayer(layer); - }), - selected_layer_styles: styleStore.selectedLayerStyles, left_sidebar_open: appStore.openSidebars.includes('left'), right_sidebar_open: appStore.openSidebars.includes('right'), panel_arrangement: panelArrangement, From 8021307a5b2c39812c1a86c86513384e41a71258 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Thu, 26 Feb 2026 11:30:52 -0500 Subject: [PATCH 20/35] fix: Ensure view styles are not overwritten by LayerStyle component reset --- web/src/components/sidebars/LayerStyle.vue | 4 ++++ web/src/store/project.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/web/src/components/sidebars/LayerStyle.vue b/web/src/components/sidebars/LayerStyle.vue index 0404a81d..f67adda6 100644 --- a/web/src/components/sidebars/LayerStyle.vue +++ b/web/src/components/sidebars/LayerStyle.vue @@ -128,6 +128,10 @@ async function init() { } function resetCurrentStyle() { + if (projectStore.currentView && !projectStore.currentViewLoaded) { + // If styles are being applied from a view, don't overwrite them + return + } // When copying styles, use deep copies via cloneDeep // so that changes to the current style do not affect the original copy if (currentLayerStyle.value?.id) { diff --git a/web/src/store/project.ts b/web/src/store/project.ts index 5d827d61..b53d582b 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -281,6 +281,8 @@ export const useProjectStore = defineStore('project', () => { availableDatasets, availableDatasetTags, availableViews, + currentView, + currentViewLoaded, fetchProjectDatasets, fetchAvailableDatasetTags, fetchProjectViews, From c2e73556bb3f215a809e2f8be8d8e4d4edce1e2b Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Thu, 26 Feb 2026 12:57:40 -0500 Subject: [PATCH 21/35] fix: Update formatting of view names in list (wrap long names) --- web/src/components/ControlsBar.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/components/ControlsBar.vue b/web/src/components/ControlsBar.vue index c2c385de..9a29bc95 100644 --- a/web/src/components/ControlsBar.vue +++ b/web/src/components/ControlsBar.vue @@ -334,10 +334,15 @@ watch(newBasemapStyleJSON, createNewBasemapPreview) + @click="projectStore.navigateToView(view)"> +