diff --git a/geoinsight/core/admin.py b/geoinsight/core/admin.py index 5cd3831d..d49c8749 100644 --- a/geoinsight/core/admin.py +++ b/geoinsight/core/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from geoinsight.core.models import ( + Basemap, Chart, ColorConfig, Colormap, @@ -25,6 +26,11 @@ ) +@admin.register(Basemap) +class BasemapAdmin(admin.ModelAdmin): + list_display = ['id', 'name'] + + @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): list_display = ['id', 'name'] diff --git a/geoinsight/core/migrations/0017_basemaps.py b/geoinsight/core/migrations/0017_basemaps.py new file mode 100644 index 00000000..b3ff5dc3 --- /dev/null +++ b/geoinsight/core/migrations/0017_basemaps.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.8 on 2025-12-10 16:19 + +import json +from pathlib import Path + +from django.conf import settings +from django.db import migrations, models + + +def forwards(apps, schema_editor): + basemap_model = apps.get_model('core', 'Basemap') + default_basemaps_file = Path(settings.BASE_DIR, 'geoinsight', 'core', 'models', 'basemaps.json') + with open(default_basemaps_file) as f: + default_basemaps = json.load(f) + for default_basemap in default_basemaps: + name = default_basemap.get('name') + style = default_basemap.get('style') + if name is not None and style is not None: + basemap_model.objects.create( + name=name, + style=style, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_dataset_owner_and_tags'), + ] + + operations = [ + migrations.CreateModel( + name='Basemap', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name='ID' + ), + ), + ('name', models.CharField(max_length=100)), + ('style', models.JSONField()), + ], + ), + migrations.RunPython(forwards, migrations.RunPython.noop), + ] diff --git a/geoinsight/core/models/__init__.py b/geoinsight/core/models/__init__.py index 9d2515a2..72fddf16 100644 --- a/geoinsight/core/models/__init__.py +++ b/geoinsight/core/models/__init__.py @@ -1,3 +1,4 @@ +from .basemap import Basemap from .chart import Chart from .colormap import Colormap from .data import RasterData, VectorData, VectorFeature @@ -18,6 +19,7 @@ from .task_result import TaskResult __all__ = [ + 'Basemap', 'TaskResult', 'Chart', 'Colormap', diff --git a/geoinsight/core/models/basemap.py b/geoinsight/core/models/basemap.py new file mode 100644 index 00000000..aeb21506 --- /dev/null +++ b/geoinsight/core/models/basemap.py @@ -0,0 +1,14 @@ +from django.db import models + + +class Basemap(models.Model): + name = models.CharField(max_length=100) + style = models.JSONField() + + def __str__(self): + return f'{self.name} ({self.id})' + + @classmethod + def filter_queryset_by_projects(cls, queryset, projects): + # Basemap permissions are not determined by Project permissions + return queryset diff --git a/geoinsight/core/models/basemaps.json b/geoinsight/core/models/basemaps.json new file mode 100644 index 00000000..7ec46c80 --- /dev/null +++ b/geoinsight/core/models/basemaps.json @@ -0,0 +1,84 @@ +[ + { + "name": "Basic Light", + "style": "https://demo.kitware.com/vector-maps/tiles/style/openstreetmap-openmaptiles-openfreemap-positron.json" + }, + { + "name": "Basic Dark", + "style": "https://demo.kitware.com/vector-maps/tiles/style/openstreetmap-openmaptiles-openmaptiles-dark-matter.json" + }, + { + "name": "NAIP Imagery", + "style": { + "version": 8, + "name": "NAIP", + "sources": { + "naip": { + "type": "raster", + "tiles": [ + "https://gis.apfo.usda.gov/arcgis/rest/services/NAIP/USDA_CONUS_PRIME/ImageServer/tile/{z}/{y}/{x}?blankTile=false" + ], + "tileSize": 256 + } + }, + "layers": [ + { + "id": "naip-tiles", + "type": "raster", + "source": "naip" + } + ] + } + }, + { + "name": "OSM", + "style": { + "version": 8, + "sources": { + "osm": { + "type": "raster", + "tiles": [ + "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png" + ], + "tileSize": 256, + "maxzoom": 19 + } + }, + "layers": [ + { + "id": "osm", + "type": "raster", + "source": "osm" + } + ] + } + }, + { + "name": "ArcGIS Hybrid", + "style": "https://raw.githubusercontent.com/go2garret/maps/main/src/assets/json/arcgis_hybrid.json" + }, + { + "name": "CartoCDN Voyager", + "style": "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json" + }, + { + "name": "ICGC Dark", + "style": "https://geoserveis.icgc.cat/contextmaps/icgc_mapa_base_fosc.json" + }, + { + "name": "ICGC Dark Shadow", + "style": "https://geoserveis.icgc.cat/contextmaps/icgc_ombra_fosca.json" + }, + { + "name": "ICGC Standard Orthophoto", + "style": "https://geoserveis.icgc.cat/contextmaps/icgc_orto_estandard.json" + }, + { + "name": "ICGC Standard Orthophoto Gray", + "style": "https://geoserveis.icgc.cat/contextmaps/icgc_orto_estandard_gris.json" + }, + { + "name": "ICGC Hybrid Orthophoto", + "style": "https://geoserveis.icgc.cat/contextmaps/icgc_orto_hibrida.json" + } +] diff --git a/geoinsight/core/rest/__init__.py b/geoinsight/core/rest/__init__.py index 50b643a1..cb7ae3a7 100644 --- a/geoinsight/core/rest/__init__.py +++ b/geoinsight/core/rest/__init__.py @@ -1,4 +1,5 @@ from .analytics import AnalyticsViewSet +from .basemap import BasemapViewSet from .chart import ChartViewSet from .colormap import ColormapViewSet from .data import RasterDataViewSet, VectorDataViewSet @@ -12,6 +13,7 @@ __all__ = [ 'AnalyticsViewSet', + 'BasemapViewSet', 'ChartViewSet', 'ColormapViewSet', 'LayerViewSet', diff --git a/geoinsight/core/rest/basemap.py b/geoinsight/core/rest/basemap.py new file mode 100644 index 00000000..04d86fa0 --- /dev/null +++ b/geoinsight/core/rest/basemap.py @@ -0,0 +1,13 @@ +from rest_framework.viewsets import ModelViewSet + +from geoinsight.core.models import Basemap +from geoinsight.core.rest.access_control import GuardianFilter, GuardianPermission +from geoinsight.core.rest.serializers import BasemapSerializer + + +class BasemapViewSet(ModelViewSet): + queryset = Basemap.objects.all() + serializer_class = BasemapSerializer + permission_classes = [GuardianPermission] + filter_backends = [GuardianFilter] + lookup_field = 'id' diff --git a/geoinsight/core/rest/serializers.py b/geoinsight/core/rest/serializers.py index b149d5f9..1c675253 100644 --- a/geoinsight/core/rest/serializers.py +++ b/geoinsight/core/rest/serializers.py @@ -4,6 +4,7 @@ from rest_framework import serializers from geoinsight.core.models import ( + Basemap, Chart, Colormap, Dataset, @@ -29,6 +30,12 @@ class Meta: fields = ['id', 'username', 'email', 'first_name', 'last_name', 'is_superuser'] +class BasemapSerializer(serializers.ModelSerializer): + class Meta: + model = Basemap + fields = '__all__' + + class ProjectPermissionsSerializer(serializers.Serializer): owner_id = serializers.IntegerField() collaborator_ids = serializers.ListField(child=serializers.IntegerField()) diff --git a/geoinsight/urls.py b/geoinsight/urls.py index 614646f4..b46e424c 100644 --- a/geoinsight/urls.py +++ b/geoinsight/urls.py @@ -9,6 +9,7 @@ from geoinsight.core.rest import ( AnalyticsViewSet, + BasemapViewSet, ChartViewSet, ColormapViewSet, DatasetViewSet, @@ -45,6 +46,7 @@ router.register(r'vectors', VectorDataViewSet, basename='vectors') router.register(r'source-regions', RegionViewSet, basename='source-regions') router.register(r'networks', NetworkViewSet, basename='networks') +router.register(r'basemaps', BasemapViewSet, basename='basemaps') router.register(r'analytics', AnalyticsViewSet, basename='analytics') diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 15b2f54f..28a2a0b6 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -20,12 +20,21 @@ import { LayerFrame, TaskResult, Colormap, + Basemap, } from "@/types"; export async function getUsers(): Promise { return (await apiClient.get('users/')).data.results; } +export async function getBasemaps(): Promise { + return (await apiClient.get('basemaps/')).data.results; +} + +export async function createBasemap(basemap: Basemap): Promise { + return (await apiClient.post('basemaps/', basemap)).data; +} + export async function getProjects(): Promise { return (await apiClient.get('projects/')).data.results; } diff --git a/web/src/components/ControlsBar.vue b/web/src/components/ControlsBar.vue index a295618a..d8b28b16 100644 --- a/web/src/components/ControlsBar.vue +++ b/web/src/components/ControlsBar.vue @@ -1,8 +1,15 @@ @@ -161,4 +366,24 @@ function takeScreenshot(save: boolean) { justify-content: space-between; margin-bottom: 5px; } + +.basemap-list .v-list-item { + height: 60px; +} + +.basemap-list .v-list-item__prepend>.v-icon~.v-list-item__spacer { + width: 5px; +} + +.basemap-preview { + margin: 0px 10px; + height: 50px; + width: 50px; +} + +#basemap-preview-new { + width: 100%; + height: 150px; + border: 1px solid rgb(var(--v-theme-on-surface-variant)) +} diff --git a/web/src/components/map/Map.vue b/web/src/components/map/Map.vue index 7090f59c..b1e664e1 100644 --- a/web/src/components/map/Map.vue +++ b/web/src/components/map/Map.vue @@ -60,12 +60,13 @@ function createMap() { preserveDrawingBuffer: true, // allows screenshots // transformRequest adds auth headers to tile requests transformRequest: (url) => { - return { - url, - headers: oauthClient?.authHeaders, - }; + let headers = {} + if (url.includes(import.meta.env.VITE_APP_API_ROOT)) { + headers = oauthClient?.authHeaders + } + return { url, headers }; }, - style: THEMES[appStore.theme].mapStyle, + style: mapStore.currentBasemap?.style, center: [0, 0], zoom: 1, // Initial zoom level }); @@ -91,7 +92,7 @@ function createMap() { * 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", () => {mapStore.clickedFeature = undefined}); + newMap.on("click", () => { mapStore.clickedFeature = undefined }); // Order is important as the following function relies on the ref being set mapStore.map = newMap; @@ -119,18 +120,31 @@ function createMapControls() { } onMounted(() => { - createMap(); - mapStore.setMapCenter(undefined, true); + mapStore.fetchAvailableBasemaps().then(() => { + createMap(); + mapStore.setMapCenter(undefined, true); + }) }); +watch(() => mapStore.currentBasemap, () => { + if (mapStore.map && mapStore.currentBasemap) { + const visible = mapStore.currentBasemap.id !== undefined + mapStore.setBasemapVisibility(visible); + if (visible) { + const map = mapStore.getMap(); + if (mapStore.currentBasemap.style) { + map.setStyle(mapStore.currentBasemap.style); + } + map.once('idle', () => { + layerStore.updateLayersShown(); + }); + } + } +}) + watch(() => appStore.theme, () => { - const map = mapStore.getMap(); - map.once('idle', () => { - layerStore.updateLayersShown(); - }); - map.setStyle(THEMES[appStore.theme].mapStyle); + mapStore.setBasemapToDefault(); setAttributionControlStyle(); - layerStore.updateLayersShown(); }); watch(() => appStore.openSidebars, () => { diff --git a/web/src/store/layer.ts b/web/src/store/layer.ts index ecebf59f..ccb9a347 100644 --- a/web/src/store/layer.ts +++ b/web/src/store/layer.ts @@ -151,6 +151,8 @@ export const useLayerStore = defineStore('layer', () => { watch(selectedLayers, updateLayersShown); watch(framesByLayerId, updateLayersShown); function updateLayersShown() { + if (!mapStore.map) return; // map not yet initialized + const map = mapStore.getMap(); const userMapLayers = mapStore.getUserMapLayers(); diff --git a/web/src/store/map.ts b/web/src/store/map.ts index 49068e68..09405f2c 100644 --- a/web/src/store/map.ts +++ b/web/src/store/map.ts @@ -11,16 +11,17 @@ import { MapLibreLayerMetadata, Layer, StyleFilter, + Basemap, } from '@/types'; import { Map, MapLayerMouseEvent, Popup, Source, - LayerSpecification, VectorSourceSpecification, + LayerSpecification, } from "maplibre-gl"; -import { getRasterDataValues } from '@/api/rest'; +import { getBasemaps, getRasterDataValues } from '@/api/rest'; import { baseURL } from '@/api/auth'; import proj4 from 'proj4'; -import { useStyleStore, useLayerStore } from '.'; +import { useStyleStore, useLayerStore, useAppStore } from '.'; function getLayerIsVisible(layer: MapLibreLayerWithMetadata) { // Since visibility must be 'visible' for a feature click to even be registered, @@ -40,7 +41,6 @@ function getLayerIsVisible(layer: MapLibreLayerWithMetadata) { return opaque; } - function sourceIdFromMapLayerId(mapLayerId: string) { return mapLayerId.split('.').slice(0, -1).join('.'); } @@ -73,7 +73,6 @@ function sourceIdFromLayerFrame(layer: Layer, frame: LayerFrame) { return parts.join('.'); } - type SourceType = 'vector' | 'raster' | 'bounds'; interface SourceDescription { layerId: number; @@ -102,7 +101,6 @@ function parseSourceString(sourceId: string): SourceDescription { } } - interface LayerDescription extends SourceDescription { layerType: LayerSpecification['type'] } @@ -128,7 +126,8 @@ function parseLayerString(layerId: string): LayerDescription { export const useMapStore = defineStore('map', () => { const map = shallowRef(); - const showMapBaseLayer = ref(true); + const availableBasemaps = ref([]); + const currentBasemap = ref(); const tooltipOverlay = ref(); const clickedFeature = ref(); const rasterTooltipDataCache = ref>({}); @@ -136,6 +135,38 @@ export const useMapStore = defineStore('map', () => { const styleStore = useStyleStore(); const layerStore = useLayerStore(); + const appStore = useAppStore(); + + async function fetchAvailableBasemaps() { + availableBasemaps.value = [ + {name: 'None'}, + ...await getBasemaps() + ]; + setBasemapToDefault(); + } + + function setBasemapToDefault() { + if (!currentBasemap.value || currentBasemap.value.name.toLowerCase().includes('basic')) { + currentBasemap.value = availableBasemaps.value.find((basemap) => { + return basemap.name.toLowerCase() === 'basic ' + appStore.theme + }) + } + } + + function setBasemapVisibility(visible: boolean) { + const map = getMap(); + const baseLayerSourceIds = getBaseLayerSourceIds(); + map.getLayersOrder().forEach((id) => { + const layer = map.getLayer(id); + if (layer && baseLayerSourceIds.includes(layer.source)) { + map.setLayoutProperty( + id, + "visibility", + visible ? "visible" : "none" + ); + } + }); + } function handleLayerClick(e: MapLayerMouseEvent) { const map = getMap(); @@ -168,26 +199,6 @@ export const useMapStore = defineStore('map', () => { } } - // Update the base layer visibility - watch(showMapBaseLayer, () => { - const map = getMap(); - const baseLayerSourceIds = getBaseLayerSourceIds(); - map.getLayersOrder().forEach((id) => { - const layer = map.getLayer(id); - if (layer && baseLayerSourceIds.includes(layer.source)) { - map.setLayoutProperty( - id, - "visibility", - showMapBaseLayer.value ? "visible" : "none" - ); - } - }); - }); - - function toggleBaseLayer() { - showMapBaseLayer.value = !showMapBaseLayer.value; - } - function getMap() { if (map.value === undefined) { throw new Error("Map not yet initialized!"); @@ -201,17 +212,10 @@ export const useMapStore = defineStore('map', () => { } function getBaseLayerSourceIds() { - const map = getMap(); return getMapSources() - .map((sourceId) => map.getSource(sourceId)) - .filter((source) => { - if (source === undefined) return false; - const vectorSource = source as VectorSourceSpecification; - if (vectorSource?.url) { - return vectorSource.url.includes('demo.kitware.com'); - } - return false; - }).map((source) => source?.id); + .filter((sourceId) => ( + !sourceId || !(sourceId.includes('.vector.') || sourceId.includes('.raster.')) + )); } function getUserMapLayers() { @@ -500,14 +504,17 @@ export const useMapStore = defineStore('map', () => { return { // Data map, - showMapBaseLayer, + availableBasemaps, + currentBasemap, tooltipOverlay, clickedFeature, rasterTooltipDataCache, rasterSourceTileURLs, // Functions + fetchAvailableBasemaps, + setBasemapToDefault, + setBasemapVisibility, handleLayerClick, - toggleBaseLayer, getMap, getMapSources, getCurrentMapPosition, diff --git a/web/src/store/project.ts b/web/src/store/project.ts index a1e99065..9c9d32bf 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -66,7 +66,7 @@ export const useProjectStore = defineStore('project', () => { function clearState() { clearProjectState(); - mapStore.showMapBaseLayer = true; + mapStore.setBasemapToDefault(); appStore.currentError = undefined; panelStore.resetPanels(); diff --git a/web/src/themes.ts b/web/src/themes.ts index 6023777b..e733ef11 100644 --- a/web/src/themes.ts +++ b/web/src/themes.ts @@ -20,7 +20,6 @@ export const THEMES = { info: "#2196F3", warning: "#FFC107", }, - mapStyle: 'https://demo.kitware.com/vector-maps/tiles/style/openstreetmap-openmaptiles-openfreemap-positron.json' }, dark: { dark: true, @@ -44,6 +43,5 @@ export const THEMES = { info: "#42A5F5", warning: "#FFB74D", }, - mapStyle: 'https://demo.kitware.com/vector-maps/tiles/style/openstreetmap-openmaptiles-openmaptiles-dark-matter.json' }, }; diff --git a/web/src/types.ts b/web/src/types.ts index 3a7d3f83..23dfcac8 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,4 +1,4 @@ -import { MapGeoJSONFeature } from "maplibre-gl"; +import { MapGeoJSONFeature, StyleSpecification } from "maplibre-gl"; export interface User { id: number; @@ -9,6 +9,12 @@ export interface User { is_superuser: boolean; } +export interface Basemap { + id?: number; + name: string; + style?: string | StyleSpecification; +} + export interface Dataset { id: number; name?: string;