From c40e1d443ff852850162bd479248f02411368f12 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Thu, 28 Nov 2024 09:35:34 +0000 Subject: [PATCH 01/11] fix(backend): file upload methods in s3.py --- src/backend/app/s3.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/backend/app/s3.py b/src/backend/app/s3.py index a280f385f1..9c71a232ef 100644 --- a/src/backend/app/s3.py +++ b/src/backend/app/s3.py @@ -67,20 +67,26 @@ def object_exists(bucket_name: str, s3_path: str) -> bool: ) from e -def add_file_to_bucket(bucket_name: str, file_path: str, s3_path: str): +def add_file_to_bucket( + bucket_name: str, + s3_path: str, + file_path: str, + content_type="application/octet-stream", +): """Upload a file from the filesystem to an S3 bucket. Args: bucket_name (str): The name of the S3 bucket. - file_path (str): The path to the file on the local filesystem. s3_path (str): The path in the S3 bucket where the file will be stored. + file_path (str): The path to the file on the local filesystem. + content_type (str): The file mimetype, default application/octet-stream. """ # Ensure s3_path starts with a forward slash if not s3_path.startswith("/"): s3_path = f"/{s3_path}" client = s3_client() - client.fput_object(bucket_name, file_path, s3_path) + client.fput_object(bucket_name, s3_path, file_path, content_type=content_type) def add_obj_to_bucket( @@ -110,7 +116,12 @@ def add_obj_to_bucket( file_obj.seek(0) result = client.put_object( - bucket_name, s3_path, file_obj, file_obj.getbuffer().nbytes, **kwargs + bucket_name, + s3_path, + file_obj, + file_obj.getbuffer().nbytes, + content_type=content_type, + **kwargs, ) log.debug( f"Created {result.object_name} object; etag: {result.etag}, " From 1b0980e47289f28ffe3f235f4398314079f24e66 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Thu, 28 Nov 2024 09:36:32 +0000 Subject: [PATCH 02/11] fix(backend): upload basemaps to s3, remove get/download endpoint --- src/backend/app/projects/project_crud.py | 39 ++++++++-- src/backend/app/projects/project_routes.py | 80 +++++++++++---------- src/backend/app/projects/project_schemas.py | 2 +- 3 files changed, 76 insertions(+), 45 deletions(-) diff --git a/src/backend/app/projects/project_crud.py b/src/backend/app/projects/project_crud.py index 98bbe6fffd..e3cfb29532 100644 --- a/src/backend/app/projects/project_crud.py +++ b/src/backend/app/projects/project_crud.py @@ -49,7 +49,7 @@ split_geojson_by_task_areas, ) from app.projects import project_deps, project_schemas -from app.s3 import add_obj_to_bucket +from app.s3 import add_file_to_bucket, add_obj_to_bucket TILESDIR = "/opt/tiles" @@ -686,6 +686,7 @@ async def get_project_features_geojson( def generate_project_basemap( db: Connection, project_id: int, + org_id: int, background_task_id: uuid.UUID, source: str, output_format: str = "mbtiles", @@ -693,9 +694,12 @@ def generate_project_basemap( ): """Get the tiles for a project. + FIXME waiting on hotosm/basemap-api project to replace this + Args: db (Connection): The database connection. project_id (int): ID of project to create tiles for. + org_id (int): Organisation ID that the project falls within. background_task_id (uuid.UUID): UUID of background task to track. source (str): Tile source ("esri", "bing", "google", "custom" (tms)). output_format (str, optional): Default "mbtiles". @@ -711,8 +715,6 @@ def generate_project_basemap( # NOTE mbtile max supported zoom level is 22 (in GDAL at least) zooms = "12-22" if tms else "12-19" tiles_dir = f"{TILESDIR}/{project_id}" - # FIXME for now this is still a location on disk and we do not upload to S3 - # FIXME waiting on basemap-api project to replace this with URL outfile = f"{tiles_dir}/{project_id}_{source}tiles.{output_format}" # NOTE here we put the connection in autocommit mode to ensure we get @@ -730,7 +732,6 @@ def generate_project_basemap( background_task_id=background_task_id, status=BackgroundTaskStatus.PENDING, tile_source=source, - url=outfile, ), ) @@ -761,11 +762,37 @@ def generate_project_basemap( ) log.info(f"Basemap created for project ID {project_id}: {outfile}") + # Generate S3 urls + # We parse as BasemapOut to calculated computed fields (format, mimetype) + basemap_out = project_schemas.BasemapOut( + **new_basemap.model_dump(exclude=["url"]), + url=outfile, + ) + basemap_s3_path = ( + f"{org_id}/{project_id}/basemaps/{basemap_out.id}.{basemap_out.format}" + ) + log.debug(f"Uploading basemap to S3 path: {basemap_s3_path}") + add_file_to_bucket( + settings.S3_BUCKET_NAME, + basemap_s3_path, + outfile, + content_type=basemap_out.mimetype, + ) + basemap_external_s3_url = ( + f"{settings.S3_DOWNLOAD_ROOT}/{settings.S3_BUCKET_NAME}/{basemap_s3_path}" + ) + log.info(f"Upload of basemap to S3 complete: {basemap_external_s3_url}") + # Delete file on disk + Path(outfile).unlink(missing_ok=True) + update_basemap_sync = async_to_sync(DbBasemap.update) update_basemap_sync( db, - new_basemap.id, - project_schemas.BasemapUpdate(status=BackgroundTaskStatus.SUCCESS), + basemap_out.id, + project_schemas.BasemapUpdate( + url=basemap_external_s3_url, + status=BackgroundTaskStatus.SUCCESS, + ), ) update_bg_task_sync = async_to_sync(DbBackgroundTask.update) diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 67eae45d70..d4b7ef7243 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -22,7 +22,6 @@ from io import BytesIO from pathlib import Path from typing import Annotated, Optional -from uuid import UUID import requests from fastapi import ( @@ -37,7 +36,7 @@ UploadFile, ) from fastapi.concurrency import run_in_threadpool -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse from fmtm_splitter.splitter import split_by_sql, split_by_square from geojson_pydantic import FeatureCollection from loguru import logger as log @@ -314,42 +313,45 @@ async def tiles_list( return await DbBasemap.all(db, project_user.get("project").id) -@router.get( - "/{project_id}/tiles/{tile_id}", - response_model=project_schemas.BasemapOut, -) -async def download_tiles( - tile_id: UUID, - db: Annotated[Connection, Depends(db_conn)], - project_user: Annotated[ProjectUserDict, Depends(mapper)], -): - """Download the basemap tile archive for a project.""" - log.debug("Getting basemap path from DB") - try: - db_basemap = await DbBasemap.one(db, tile_id) - except KeyError as e: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) from e - - log.info(f"User requested download for tiles: {db_basemap.url}") - - project = project_user.get("project") - filename = Path(db_basemap.url).name.replace(f"{project.id}_", f"{project.slug}_") - log.debug(f"Sending tile archive to user: {filename}") - - if db_basemap.format == "mbtiles": - mimetype = "application/vnd.mapbox-vector-tile" - elif db_basemap.format == "pmtiles": - mimetype = "application/vnd.pmtiles" - else: - mimetype = "application/vnd.sqlite3" - - return FileResponse( - db_basemap.url, - headers={ - "Content-Disposition": f"attachment; filename={filename}", - "Content-Type": mimetype, - }, - ) +# NOTE we no longer need this as tiles are uploaded to S3 +# However, it could be useful if requiring private buckets in +# the future, with pre-signed URL generation +# @router.get( +# "/{project_id}/tiles/{tile_id}", +# response_model=project_schemas.BasemapOut, +# ) +# async def download_tiles( +# tile_id: UUID, +# db: Annotated[Connection, Depends(db_conn)], +# project_user: Annotated[ProjectUserDict, Depends(mapper)], +# ): +# """Download the basemap tile archive for a project.""" +# log.debug("Getting basemap path from DB") +# try: +# db_basemap = await DbBasemap.one(db, tile_id) +# except KeyError as e: +# raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) from e + +# log.info(f"User requested download for tiles: {db_basemap.url}") + +# project = project_user.get("project") +# filename = Path(db_basemap.url).name.replace(f"{project.id}_", f"{project.slug}_") +# log.debug(f"Sending tile archive to user: {filename}") + +# if db_basemap.format == "mbtiles": +# mimetype = "application/vnd.mapbox-vector-tile" +# elif db_basemap.format == "pmtiles": +# mimetype = "application/vnd.pmtiles" +# else: +# mimetype = "application/vnd.sqlite3" + +# return FileResponse( +# db_basemap.url, +# headers={ +# "Content-Disposition": f"attachment; filename={filename}", +# "Content-Type": mimetype, +# }, +# ) @router.get("/categories") @@ -956,6 +958,7 @@ async def generate_project_basemap( ): """Returns basemap tiles for a project.""" project_id = project_user.get("project").id + org_id = project_user.get("project").organisation_id # Create task in db and return uuid log.debug( @@ -974,6 +977,7 @@ async def generate_project_basemap( project_crud.generate_project_basemap, db, project_id, + org_id, background_task_id, basemap_in.tile_source, basemap_in.file_format, diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 9a45279226..7e7aa8676a 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -286,9 +286,9 @@ class BasemapIn(DbBasemap): id: Annotated[Optional[UUID], Field(exclude=True)] = None project_id: int tile_source: str - url: str background_task_id: UUID status: BackgroundTaskStatus + # 'url' not set to mandatory, as it can be updated after upload class BasemapUpdate(DbBasemap): From 5e058514d2745d618821778d594c612a7267f072 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Thu, 28 Nov 2024 09:37:00 +0000 Subject: [PATCH 03/11] fix(frontend): access basemap urls directly from s3 --- src/frontend/src/api/Project.ts | 43 +++------ .../src/components/GenerateBasemap.tsx | 10 +-- src/frontend/src/store/types/IProject.ts | 1 + .../lib/components/offline/basemaps.svelte | 27 +++--- src/mapper/src/lib/utils/basemaps.ts | 89 +++++++------------ 5 files changed, 63 insertions(+), 107 deletions(-) diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index c994ff8fd2..1a91e5570d 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -169,22 +169,18 @@ export const GenerateProjectTiles = (url: string, projectId: string, data: objec }; }; -export const DownloadTile = (url: string, payload: Partial, toOpfs: boolean = false) => { +export const DownloadTile = (url: string, projectId: string | null) => { return async (dispatch) => { - dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: true })); + dispatch(ProjectActions.SetDownloadTileLoading({ loading: true })); - const getDownloadTile = async (url: string, payload: Partial, toOpfs: boolean) => { + const getDownloadTile = async (url: string, projectId: string | null) => { try { - const response = await CoreModules.axios.get(url, { - responseType: 'arraybuffer', - }); - - // Get filename from Content-Disposition header - const tileData = response.data; - - if (toOpfs) { + if (projectId) { + const response = await CoreModules.axios.get(url, { + responseType: 'arraybuffer', + }); + const tileData = response.data; // Copy to OPFS filesystem for offline use - const projectId = payload.id; const filePath = `${projectId}/all.pmtiles`; await writeBinaryToOPFS(filePath, tileData); // Set the OPFS file path to project state @@ -192,28 +188,17 @@ export const DownloadTile = (url: string, payload: Partial, toO return; } - const filename = response.headers['content-disposition'].split('filename=')[1]; - console.log(filename); - // Create Blob from ArrayBuffer - const blob = new Blob([tileData], { type: response.headers['content-type'] }); - const downloadUrl = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = filename; - a.click(); - - // Clean up object URL - URL.revokeObjectURL(downloadUrl); + // Open S3 url directly + window.open(url); - dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); + dispatch(ProjectActions.SetDownloadTileLoading({ loading: false })); } catch (error) { - dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); + dispatch(ProjectActions.SetDownloadTileLoading({ loading: false })); } finally { - dispatch(ProjectActions.SetDownloadTileLoading({ type: payload, loading: false })); + dispatch(ProjectActions.SetDownloadTileLoading({ loading: false })); } }; - await getDownloadTile(url, payload, toOpfs); + await getDownloadTile(url, projectId); }; }; diff --git a/src/frontend/src/components/GenerateBasemap.tsx b/src/frontend/src/components/GenerateBasemap.tsx index dde23eeedc..8faf542f75 100644 --- a/src/frontend/src/components/GenerateBasemap.tsx +++ b/src/frontend/src/components/GenerateBasemap.tsx @@ -3,7 +3,7 @@ import CoreModules from '@/shared/CoreModules'; import AssetModules from '@/shared/AssetModules'; import { CommonActions } from '@/store/slices/CommonSlice'; import environment from '@/environment'; -import { DownloadTile, GenerateProjectTiles, GetTilesList } from '@/api/Project'; +import { DownloadTile, GenerateProjectTiles, GetTilesList, ProjectById } from '@/api/Project'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { projectInfoType } from '@/models/project/projectModel'; import { useAppSelector } from '@/types/reduxTypes'; @@ -31,8 +31,8 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial { - dispatch(DownloadTile(`${import.meta.env.VITE_API_URL}/projects/${id}/tiles/${tileId}`, projectInfo, toOpfs)); + const downloadBasemap = (url, toOpfs = false) => { + dispatch(DownloadTile(url, toOpfs ? id : null)); }; const getTilesList = () => { @@ -308,14 +308,14 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial downloadBasemap(list.id, true)} + onClick={() => downloadBasemap(list.url, true)} className="fmtm-text-red-500 hover:fmtm-text-red-700" /> )} {list.status === 'SUCCESS' && ( downloadBasemap(list.id)} + onClick={() => downloadBasemap(list.url)} className="fmtm-text-gray-500 hover:fmtm-text-blue-500" /> )} diff --git a/src/frontend/src/store/types/IProject.ts b/src/frontend/src/store/types/IProject.ts index db6be9ccac..4321bc84ee 100644 --- a/src/frontend/src/store/types/IProject.ts +++ b/src/frontend/src/store/types/IProject.ts @@ -53,6 +53,7 @@ type tilesListTypes = { status: string; source: string; format: string; + url: string; }; type projectCommentsListTypes = { diff --git a/src/mapper/src/lib/components/offline/basemaps.svelte b/src/mapper/src/lib/components/offline/basemaps.svelte index 37048fe78a..efd381bc08 100644 --- a/src/mapper/src/lib/components/offline/basemaps.svelte +++ b/src/mapper/src/lib/components/offline/basemaps.svelte @@ -1,7 +1,6 @@ @@ -213,7 +251,12 @@ - + diff --git a/src/mapper/src/lib/utils/basemaps.ts b/src/mapper/src/lib/utils/basemaps.ts index fbae9174f5..485ec106da 100644 --- a/src/mapper/src/lib/utils/basemaps.ts +++ b/src/mapper/src/lib/utils/basemaps.ts @@ -46,7 +46,7 @@ export async function loadOnlinePmtiles(url: string | null) { } export async function loadOfflinePmtiles(projectId: number) { - const filePath = `${projectId}/all.pmtiles`; + const filePath = `${projectId}/basemap.pmtiles`; // Read file from OPFS and display on map const opfsPmtilesData = await readFileFromOPFS(filePath); @@ -91,7 +91,7 @@ export async function writeOfflinePmtiles(projectId: number, url: string | null) const data = await downloadBasemap(url); // Copy to OPFS filesystem for offline use - const filePath = `${projectId}/all.pmtiles`; + const filePath = `${projectId}/basemap.pmtiles`; await writeBinaryToOPFS(filePath, data); await loadOfflinePmtiles(projectId); diff --git a/src/mapper/src/store/common.svelte.ts b/src/mapper/src/store/common.svelte.ts index ec52690918..2fe6436a90 100644 --- a/src/mapper/src/store/common.svelte.ts +++ b/src/mapper/src/store/common.svelte.ts @@ -57,10 +57,6 @@ function getProjectBasemapStore() { }, setProjectPmtilesUrl: (url: string) => { projectPmtilesUrl = url; - getAlertStore().setAlert({ - variant: 'success', - message: 'Success! Check the base layer selector.', - }); }, }; } diff --git a/src/mapper/svelte.config.js b/src/mapper/svelte.config.js index 52f5ed58e2..94a12ba292 100644 --- a/src/mapper/svelte.config.js +++ b/src/mapper/svelte.config.js @@ -31,9 +31,10 @@ const config = { alias: { $lib: 'src/lib', $components: 'src/components', - $static: 'static', $store: 'src/store', $routes: 'src/routes', + $constants: 'src/constants', + $static: 'static', $styles: 'styles', $assets: 'assets', }, diff --git a/src/mapper/vite.config.ts b/src/mapper/vite.config.ts index 377aabf934..8accf8d3de 100644 --- a/src/mapper/vite.config.ts +++ b/src/mapper/vite.config.ts @@ -22,12 +22,12 @@ export default defineConfig({ alias: { $lib: path.resolve('./src/lib'), $components: path.resolve('./src/components'), - $static: path.resolve('./static'), $store: path.resolve('./src/store'), $routes: path.resolve('./src/routes'), + $constants: path.resolve('./src/constants'), + $static: path.resolve('./static'), $styles: path.resolve('./src/styles'), $assets: path.resolve('./src/assets'), - $constants: path.resolve('./src/constants'), }, }, test: { From 0fd6a15f2e14b7caaf3f8253c093392d9526b466 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 30 Nov 2024 10:25:27 +0000 Subject: [PATCH 08/11] refactor: remove console log on search for opfs basemap --- src/mapper/src/lib/fs/opfs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mapper/src/lib/fs/opfs.ts b/src/mapper/src/lib/fs/opfs.ts index 167f07eab2..a2e87c5409 100644 --- a/src/mapper/src/lib/fs/opfs.ts +++ b/src/mapper/src/lib/fs/opfs.ts @@ -6,7 +6,7 @@ export async function readFileFromOPFS(filePath: string): Promise { // Iterate dirs and get directory handles for (const directory of directories.slice(0, -1)) { - console.log(`Reading OPFS dir: ${directory}`); + // console.log(`Reading OPFS dir: ${directory}`); try { currentDirectoryHandle = await currentDirectoryHandle.getDirectoryHandle(directory); } catch { From afe61c6669bf391bb21956b63b68f60a79cfa516 Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 30 Nov 2024 10:25:59 +0000 Subject: [PATCH 09/11] refactor: remove custom logic for loading offline pmtiles in react frontend --- src/frontend/package.json | 1 + src/frontend/pnpm-lock.yaml | 21 ++++++ src/frontend/src/api/Files.ts | 66 ------------------- src/frontend/src/api/Project.ts | 21 +----- .../src/components/GenerateBasemap.tsx | 4 +- .../LayerSwitcher/LayerSwitchMenu.tsx | 6 +- .../LayerSwitcher/index.js | 45 ++++--------- .../ProjectDetailsV2/MapControlComponent.tsx | 6 +- src/frontend/src/store/slices/ProjectSlice.ts | 4 -- src/frontend/src/store/types/IProject.ts | 1 - src/frontend/src/views/ProjectDetailsV2.tsx | 42 ++---------- 11 files changed, 52 insertions(+), 165 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index 3b88535d87..225da2fa82 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -87,6 +87,7 @@ "ol": "^8.0.0", "ol-ext": "^4.0.11", "ol-layerswitcher": "^4.1.1", + "ol-pmtiles": "^1.0.2", "pako": "^2.1.0", "pmtiles": "^3.0.6", "qrcode-generator": "^1.4.4", diff --git a/src/frontend/pnpm-lock.yaml b/src/frontend/pnpm-lock.yaml index 41c6f6bb13..21ed459a22 100644 --- a/src/frontend/pnpm-lock.yaml +++ b/src/frontend/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: ol-layerswitcher: specifier: ^4.1.1 version: 4.1.1(ol@8.2.0) + ol-pmtiles: + specifier: ^1.0.2 + version: 1.0.2(ol@8.2.0) pako: specifier: ^2.1.0 version: 2.1.0 @@ -3566,6 +3569,11 @@ packages: peerDependencies: ol: '>=5.0.0' + ol-pmtiles@1.0.2: + resolution: {integrity: sha512-+2itEeTcOk6RWikH2/cWIvv2mFW0empLaCibon65e1kyOEzB+zHIqF3eKa15yyznV8r9K0wfx9S3aG6ceXL0hQ==} + peerDependencies: + ol: '>=9.0.0' + ol@8.2.0: resolution: {integrity: sha512-/m1ddd7Jsp4Kbg+l7+ozR5aKHAZNQOBAoNZ5pM9Jvh4Etkf0WGkXr9qXd7PnhmwiC1Hnc2Toz9XjCzBBvexfXw==} @@ -3676,6 +3684,9 @@ packages: pmtiles@3.0.6: resolution: {integrity: sha512-IdeMETd5lBIDVTLul1HFl0Q7l4KLJjzdxgcp+sN7pYvbipaV7o/0u0HiV06kaFCD0IGEN8KtUHyFZpY30WMflw==} + pmtiles@3.2.1: + resolution: {integrity: sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -8432,6 +8443,11 @@ snapshots: dependencies: ol: 8.2.0 + ol-pmtiles@1.0.2(ol@8.2.0): + dependencies: + ol: 8.2.0 + pmtiles: 3.2.1 + ol@8.2.0: dependencies: color-rgba: 3.0.0 @@ -8541,6 +8557,11 @@ snapshots: '@types/leaflet': 1.9.8 fflate: 0.8.2 + pmtiles@3.2.1: + dependencies: + '@types/leaflet': 1.9.8 + fflate: 0.8.2 + possible-typed-array-names@1.0.0: {} postcss-import@15.1.0(postcss@8.4.38): diff --git a/src/frontend/src/api/Files.ts b/src/frontend/src/api/Files.ts index f130f68186..e6866728f2 100755 --- a/src/frontend/src/api/Files.ts +++ b/src/frontend/src/api/Files.ts @@ -55,69 +55,3 @@ export const GetProjectQrCode = ( }, [projectName, odkToken, osmUser]); return { qrcode }; }; - -export async function readFileFromOPFS(filePath: string) { - const opfsRoot = await navigator.storage.getDirectory(); - const directories = filePath.split('/'); - - let currentDirectoryHandle = opfsRoot; - - // Iterate dirs and get directoryHandles - for (const directory of directories.slice(0, -1)) { - console.log(`Reading OPFS dir: ${directory}`); - try { - currentDirectoryHandle = await currentDirectoryHandle.getDirectoryHandle(directory); - } catch { - return null; // Directory doesn't exist - } - } - - // Get file within final directory handle - try { - const filename: any = directories.pop(); - console.log(`Getting OPFS file: ${filename}`); - const fileHandle = await currentDirectoryHandle.getFileHandle(filename); - const fileData = await fileHandle.getFile(); // Read the file - return fileData; - } catch { - return null; // File doesn't exist or error occurred - } -} - -export async function writeBinaryToOPFS(filePath: string, data: any) { - console.log(`Starting write to OPFS file: ${filePath}`); - - const opfsRoot = await navigator.storage.getDirectory(); - - // Split the filePath into directories and filename - const directories = filePath.split('/'); - const filename: any = directories.pop(); - - // Start with the root directory handle - let currentDirectoryHandle = opfsRoot; - - // Iterate over directories and create nested directories - for (const directory of directories) { - console.log(`Creating OPFS dir: ${directory}`); - try { - currentDirectoryHandle = await currentDirectoryHandle.getDirectoryHandle(directory, { create: true }); - } catch (error) { - console.error('Error creating directory:', error); - } - } - - // Create the file handle within the last directory - const fileHandle = await currentDirectoryHandle.getFileHandle(filename, { create: true }); - const writable = await fileHandle.createWritable(); - - // Write data to the writable stream - try { - await writable.write(data); - } catch (error) { - console.log(error); - } - - // Close the writable stream - await writable.close(); - console.log(`Finished write to OPFS file: ${filePath}`); -} diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index 1a91e5570d..8012166c1b 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -2,8 +2,6 @@ import { ProjectActions } from '@/store/slices/ProjectSlice'; import { CommonActions } from '@/store/slices/CommonSlice'; import CoreModules from '@/shared/CoreModules'; import { task_state, task_event } from '@/types/enums'; -import { writeBinaryToOPFS } from '@/api/Files'; -import { projectInfoType } from '@/models/project/projectModel'; export const ProjectById = (projectId: string) => { return async (dispatch) => { @@ -169,25 +167,12 @@ export const GenerateProjectTiles = (url: string, projectId: string, data: objec }; }; -export const DownloadTile = (url: string, projectId: string | null) => { +export const DownloadTile = (url: string) => { return async (dispatch) => { dispatch(ProjectActions.SetDownloadTileLoading({ loading: true })); - const getDownloadTile = async (url: string, projectId: string | null) => { + const getDownloadTile = async (url: string) => { try { - if (projectId) { - const response = await CoreModules.axios.get(url, { - responseType: 'arraybuffer', - }); - const tileData = response.data; - // Copy to OPFS filesystem for offline use - const filePath = `${projectId}/all.pmtiles`; - await writeBinaryToOPFS(filePath, tileData); - // Set the OPFS file path to project state - dispatch(ProjectActions.SetProjectOpfsBasemapPath(filePath)); - return; - } - // Open S3 url directly window.open(url); @@ -198,7 +183,7 @@ export const DownloadTile = (url: string, projectId: string | null) => { dispatch(ProjectActions.SetDownloadTileLoading({ loading: false })); } }; - await getDownloadTile(url, projectId); + await getDownloadTile(url); }; }; diff --git a/src/frontend/src/components/GenerateBasemap.tsx b/src/frontend/src/components/GenerateBasemap.tsx index 8faf542f75..07482c063e 100644 --- a/src/frontend/src/components/GenerateBasemap.tsx +++ b/src/frontend/src/components/GenerateBasemap.tsx @@ -31,8 +31,8 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial { - dispatch(DownloadTile(url, toOpfs ? id : null)); + const downloadBasemap = (url) => { + dispatch(DownloadTile(url)); }; const getTilesList = () => { diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx index 3f13dc8427..e06acef95f 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx @@ -56,7 +56,7 @@ const LayerCard = ({ layer, changeBaseLayerHandler, active }: layerCardPropType) ); }; -const LayerSwitchMenu = ({ map, pmTileLayerData = null }: { map: any; pmTileLayerData?: any }) => { +const LayerSwitchMenu = ({ map, pmTileLayerUrl = null }: { map: any; pmTileLayerUrl?: any }) => { const { pathname } = useLocation(); const [baseLayers, setBaseLayers] = useState(['OSM', 'Satellite', 'None']); const [hasPMTile, setHasPMTile] = useState(false); @@ -78,10 +78,10 @@ const LayerSwitchMenu = ({ map, pmTileLayerData = null }: { map: any; pmTileLaye }, [projectInfo, pathname, map]); useEffect(() => { - if (!pmTileLayerData || baseLayers.includes('PMTile')) return; + if (!pmTileLayerUrl || baseLayers.includes('PMTile')) return; setHasPMTile(true); setActiveTileLayer('PMTile'); - }, [pmTileLayerData]); + }, [pmTileLayerUrl]); const changeBaseLayer = (baseLayerTitle: string) => { const allLayers = map.getLayers(); diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js index 0d3a823e2c..a5db3bb694 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js @@ -10,7 +10,7 @@ import { XYZ } from 'ol/source'; import { useLocation } from 'react-router-dom'; import DataTile from 'ol/source/DataTile.js'; import TileLayer from 'ol/layer/WebGLTile.js'; -import { FileSource, PMTiles } from 'pmtiles'; +import { PMTilesRasterSource } from 'ol-pmtiles'; import windowDimention from '@/hooks/WindowDimension'; import { useAppSelector } from '@/types/reduxTypes'; @@ -134,41 +134,20 @@ const monochromeMidNight = (visible = false) => }), }); -const pmTileLayer = (pmTileLayerData, visible) => { - function loadImage(src) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.addEventListener('load', () => resolve(img)); - img.addEventListener('error', () => reject(new Error('load failed'))); - img.src = src; - }); - } - - const pmTiles = new PMTiles(new FileSource(pmTileLayerData)); - - async function loader(z, x, y) { - const response = await pmTiles.getZxy(z, x, y); - const blob = new Blob([response.data]); - const src = URL.createObjectURL(blob); - const image = await loadImage(src); - URL.revokeObjectURL(src); - return image; - } - return new TileLayer({ +const pmTileLayer = (pmTileLayerUrl, visible = true) => { + return new WebGLTile({ title: `PMTile`, type: 'raster pm tiles', - visible: true, - source: new DataTile({ - loader, - wrapX: true, + visible: visible, + source: new PMTilesRasterSource({ + url: pmTileLayerUrl, + attributions: ['OpenAerialMap'], tileSize: [512, 512], - maxZoom: 22, - attributions: 'Tiles © OpenAerialMap', }), }); }; -const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null }) => { +const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerUrl = null }) => { const { windowSize } = windowDimention(); const { pathname } = useLocation(); @@ -181,7 +160,7 @@ const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null }) // mapboxMap(visible), // mapboxOutdoors(visible), none(visible), - // pmTileLayer(pmTileLayerData, visible), + // pmTileLayer(pmTileLayerUrl, visible), ], }), ); @@ -246,11 +225,11 @@ const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null }) }, [map, visible]); useEffect(() => { - if (!pmTileLayerData) { + if (!pmTileLayerUrl) { return; } - const pmTileBaseLayer = pmTileLayer(pmTileLayerData, visible); + const pmTileBaseLayer = pmTileLayer(pmTileLayerUrl, visible); const currentLayers = basemapLayers.getLayers(); currentLayers.push(pmTileBaseLayer); @@ -259,7 +238,7 @@ const LayerSwitcherControl = ({ map, visible = 'osm', pmTileLayerData = null }) return () => { basemapLayers.getLayers().remove(pmTileBaseLayer); }; - }, [pmTileLayerData]); + }, [pmTileLayerUrl]); const location = useLocation(); useEffect(() => {}, [map]); diff --git a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx index 4461329eec..439d2717ec 100644 --- a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx +++ b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx @@ -15,7 +15,7 @@ import MapLegends from '@/components/MapLegends'; type mapControlComponentType = { map: any; projectName: string; - pmTileLayerData: any; + pmTileLayerUrl: any; }; const btnList = [ @@ -41,7 +41,7 @@ const btnList = [ }, ]; -const MapControlComponent = ({ map, projectName, pmTileLayerData }: mapControlComponentType) => { +const MapControlComponent = ({ map, projectName, pmTileLayerUrl }: mapControlComponentType) => { const { pathname } = useLocation(); const dispatch = CoreModules.useAppDispatch(); const [toggleCurrentLoc, setToggleCurrentLoc] = useState(false); @@ -89,7 +89,7 @@ const MapControlComponent = ({ map, projectName, pmTileLayerData }: mapControlCo ))} - + {/* download options */}
{ const [dataExtractUrl, setDataExtractUrl] = useState(); const [dataExtractExtent, setDataExtractExtent] = useState(null); const [taskBoundariesLayer, setTaskBoundariesLayer] = useState>(null); - // Can pass a File object, or a string URL to be read by PMTiles - const [customBasemapData, setCustomBasemapData] = useState(); + // FIXME currently we have no logic to retrieve the PMTiles for a project and pass + // FIXME as the customBasemapUrl. + // FIXME This should probably be triggered based on project customTmsUrl being set. + // FIXME If set, then we search for the first PMTiles archive available? + const [customBasemapUrl, setcustomBasemapUrl] = useState(); const [viewState, setViewState] = useState('project_info'); const projectId: string = params.id; const defaultTheme = useAppSelector((state) => state.theme.hotTheme); @@ -68,7 +70,6 @@ const ProjectDetailsV2 = () => { const projectDetailsLoading = useAppSelector((state) => state?.project?.projectDetailsLoading); const geolocationStatus = useAppSelector((state) => state.project.geolocationStatus); const taskModalStatus = CoreModules.useAppSelector((state) => state.project.taskModalStatus); - const projectOpfsBasemapPath = useAppSelector((state) => state?.project?.projectOpfsBasemapPath); const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails); const entityOsmMap = useAppSelector((state) => state?.project?.entityOsmMap); @@ -255,32 +256,6 @@ const ProjectDetailsV2 = () => { dispatch(GetEntityInfo(`${import.meta.env.VITE_API_URL}/projects/${projectId}/entities/statuses`)); }, []); - const getPmtilesBasemap = async () => { - if (!projectOpfsBasemapPath) { - return; - } - const opfsPmtilesData = await readFileFromOPFS(projectOpfsBasemapPath); - setCustomBasemapData(opfsPmtilesData); - }; - useEffect(() => { - if (!projectOpfsBasemapPath) { - return; - } - - // Extract project id from projectOpfsBasemapPath - const projectOpfsBasemapPathParts = projectOpfsBasemapPath.split('/'); - const projectOpfsBasemapProjectId = projectOpfsBasemapPathParts[0]; - - // Check if project id from projectOpfsBasemapPath matches current projectId - if (projectOpfsBasemapProjectId !== projectId) { - // If they don't match, set CustomBasemapData to null - setCustomBasemapData(null); - } else { - // If they match, fetch the basemap data - getPmtilesBasemap(); - } - return () => {}; - }, [projectOpfsBasemapPath]); const [showDebugConsole, setShowDebugConsole] = useState(false); return ( @@ -450,10 +425,7 @@ const ProjectDetailsV2 = () => { />
)} - + {taskBoundariesLayer && taskBoundariesLayer?.features?.length > 0 && ( {
Date: Sat, 30 Nov 2024 10:54:59 +0000 Subject: [PATCH 10/11] fix(management): allow loading custom pmtiles basemaps on react frontend --- src/frontend/src/api/Project.ts | 6 +++--- src/frontend/src/components/GenerateBasemap.tsx | 11 ++++------- .../LayerSwitcher/LayerSwitchMenu.tsx | 14 +++++++------- .../OpenLayersComponent/LayerSwitcher/index.js | 7 +++---- src/frontend/src/store/slices/ProjectSlice.ts | 4 ++++ src/frontend/src/store/types/IProject.ts | 1 + src/frontend/src/views/ProjectDetailsV2.tsx | 6 +----- 7 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/api/Project.ts b/src/frontend/src/api/Project.ts index 8012166c1b..ac14e5f84f 100755 --- a/src/frontend/src/api/Project.ts +++ b/src/frontend/src/api/Project.ts @@ -167,11 +167,11 @@ export const GenerateProjectTiles = (url: string, projectId: string, data: objec }; }; -export const DownloadTile = (url: string) => { +export const DownloadBasemapFile = (url: string) => { return async (dispatch) => { dispatch(ProjectActions.SetDownloadTileLoading({ loading: true })); - const getDownloadTile = async (url: string) => { + const downloadBasemapFromAPI = async (url: string) => { try { // Open S3 url directly window.open(url); @@ -183,7 +183,7 @@ export const DownloadTile = (url: string) => { dispatch(ProjectActions.SetDownloadTileLoading({ loading: false })); } }; - await getDownloadTile(url); + await downloadBasemapFromAPI(url); }; }; diff --git a/src/frontend/src/components/GenerateBasemap.tsx b/src/frontend/src/components/GenerateBasemap.tsx index 07482c063e..a547974031 100644 --- a/src/frontend/src/components/GenerateBasemap.tsx +++ b/src/frontend/src/components/GenerateBasemap.tsx @@ -3,7 +3,7 @@ import CoreModules from '@/shared/CoreModules'; import AssetModules from '@/shared/AssetModules'; import { CommonActions } from '@/store/slices/CommonSlice'; import environment from '@/environment'; -import { DownloadTile, GenerateProjectTiles, GetTilesList, ProjectById } from '@/api/Project'; +import { DownloadBasemapFile, GenerateProjectTiles, GetTilesList, ProjectById } from '@/api/Project'; import { ProjectActions } from '@/store/slices/ProjectSlice'; import { projectInfoType } from '@/models/project/projectModel'; import { useAppSelector } from '@/types/reduxTypes'; @@ -31,9 +31,6 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial { - dispatch(DownloadTile(url)); - }; const getTilesList = () => { dispatch(GetTilesList(`${import.meta.env.VITE_API_URL}/projects/${id}/tiles`)); @@ -306,16 +303,16 @@ const GenerateBasemap = ({ projectInfo }: { projectInfo: Partial
{list.status === 'SUCCESS' && list.format === 'pmtiles' && ( - downloadBasemap(list.url, true)} + onClick={() => dispatch(ProjectActions.SetPmtileBasemapUrl(list.url))} className="fmtm-text-red-500 hover:fmtm-text-red-700" /> )} {list.status === 'SUCCESS' && ( downloadBasemap(list.url)} + onClick={() => dispatch(DownloadBasemapFile(list.url))} className="fmtm-text-gray-500 hover:fmtm-text-blue-500" /> )} diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx index e06acef95f..55fc00f365 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/LayerSwitchMenu.tsx @@ -78,9 +78,9 @@ const LayerSwitchMenu = ({ map, pmTileLayerUrl = null }: { map: any; pmTileLayer }, [projectInfo, pathname, map]); useEffect(() => { - if (!pmTileLayerUrl || baseLayers.includes('PMTile')) return; + if (!pmTileLayerUrl || baseLayers.includes('Custom')) return; setHasPMTile(true); - setActiveTileLayer('PMTile'); + setActiveTileLayer('Custom'); }, [pmTileLayerUrl]); const changeBaseLayer = (baseLayerTitle: string) => { @@ -90,7 +90,7 @@ const LayerSwitchMenu = ({ map, pmTileLayerUrl = null }: { map: any; pmTileLayer ); const baseLayersCollection: Record[] = filteredBaseLayers?.values_?.layers.array_; baseLayersCollection - ?.filter((bLayer) => bLayer?.values_?.title !== 'PMTile') + ?.filter((bLayer) => bLayer?.values_?.title !== 'Custom') ?.forEach((baseLayer) => { if (baseLayer?.values_?.title === baseLayerTitle) { baseLayer.setVisible(true); @@ -158,10 +158,10 @@ const LayerSwitchMenu = ({ map, pmTileLayerUrl = null }: { map: any; pmTileLayer
Tiles
toggleTileLayer('PMTile')} - active={'PMTile' === activeTileLayer} + key="Custom" + layer="Custom" + changeBaseLayerHandler={() => toggleTileLayer('Custom')} + active={'Custom' === activeTileLayer} />
diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js index a5db3bb694..7bf5798c2b 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/LayerSwitcher/index.js @@ -8,8 +8,7 @@ import LayerSwitcher from 'ol-layerswitcher'; import React, { useEffect, useState } from 'react'; import { XYZ } from 'ol/source'; import { useLocation } from 'react-router-dom'; -import DataTile from 'ol/source/DataTile.js'; -import TileLayer from 'ol/layer/WebGLTile.js'; +import WebGLTile from 'ol/layer/WebGLTile.js'; import { PMTilesRasterSource } from 'ol-pmtiles'; import windowDimention from '@/hooks/WindowDimension'; import { useAppSelector } from '@/types/reduxTypes'; @@ -136,8 +135,8 @@ const monochromeMidNight = (visible = false) => const pmTileLayer = (pmTileLayerUrl, visible = true) => { return new WebGLTile({ - title: `PMTile`, - type: 'raster pm tiles', + title: 'Custom', + type: 'custom', visible: visible, source: new PMTilesRasterSource({ url: pmTileLayerUrl, diff --git a/src/frontend/src/store/slices/ProjectSlice.ts b/src/frontend/src/store/slices/ProjectSlice.ts index e035cec7df..a6d927f0cc 100755 --- a/src/frontend/src/store/slices/ProjectSlice.ts +++ b/src/frontend/src/store/slices/ProjectSlice.ts @@ -15,6 +15,7 @@ const initialState: ProjectStateTypes = { tilesList: [], tilesListLoading: false, downloadTilesLoading: false, + customBasemapUrl: null, downloadDataExtractLoading: false, taskModalStatus: false, toggleGenerateMbTilesModal: false, @@ -85,6 +86,9 @@ const ProjectSlice = createSlice({ SetDownloadTileLoading(state, action) { state.downloadTilesLoading = action.payload; }, + SetPmtileBasemapUrl(state, action) { + state.customBasemapUrl = action.payload; + }, SetDownloadDataExtractLoading(state, action) { state.downloadDataExtractLoading = action.payload; }, diff --git a/src/frontend/src/store/types/IProject.ts b/src/frontend/src/store/types/IProject.ts index cea9474f64..0419ffe177 100644 --- a/src/frontend/src/store/types/IProject.ts +++ b/src/frontend/src/store/types/IProject.ts @@ -18,6 +18,7 @@ export type ProjectStateTypes = { tilesList: tilesListTypes[]; tilesListLoading: boolean; downloadTilesLoading: boolean; + customBasemapUrl: string | null; downloadDataExtractLoading: boolean; taskModalStatus: boolean; toggleGenerateMbTilesModal: boolean; diff --git a/src/frontend/src/views/ProjectDetailsV2.tsx b/src/frontend/src/views/ProjectDetailsV2.tsx index 3b9453639f..4438fefe73 100644 --- a/src/frontend/src/views/ProjectDetailsV2.tsx +++ b/src/frontend/src/views/ProjectDetailsV2.tsx @@ -52,11 +52,7 @@ const ProjectDetailsV2 = () => { const [dataExtractUrl, setDataExtractUrl] = useState(); const [dataExtractExtent, setDataExtractExtent] = useState(null); const [taskBoundariesLayer, setTaskBoundariesLayer] = useState>(null); - // FIXME currently we have no logic to retrieve the PMTiles for a project and pass - // FIXME as the customBasemapUrl. - // FIXME This should probably be triggered based on project customTmsUrl being set. - // FIXME If set, then we search for the first PMTiles archive available? - const [customBasemapUrl, setcustomBasemapUrl] = useState(); + const customBasemapUrl = useAppSelector((state) => state.project.customBasemapUrl); const [viewState, setViewState] = useState('project_info'); const projectId: string = params.id; const defaultTheme = useAppSelector((state) => state.theme.hotTheme); From 0a437ca816359adcd4bd02d9a3d53e8ed44ebb2b Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sat, 30 Nov 2024 13:20:14 +0000 Subject: [PATCH 11/11] fix(mapper): automatically select pmtiles layer if set --- .../lib/components/map/layer-switcher.svelte | 110 ++++++++---------- src/mapper/src/lib/components/map/main.svelte | 8 +- src/mapper/src/lib/fs/opfs.ts | 2 +- 3 files changed, 51 insertions(+), 69 deletions(-) diff --git a/src/mapper/src/lib/components/map/layer-switcher.svelte b/src/mapper/src/lib/components/map/layer-switcher.svelte index f305426fcb..0367937861 100644 --- a/src/mapper/src/lib/components/map/layer-switcher.svelte +++ b/src/mapper/src/lib/components/map/layer-switcher.svelte @@ -26,34 +26,49 @@ map = new Map({ import { onDestroy } from 'svelte'; import { clickOutside } from '$lib/utils/clickOutside'; + type MapLibreStylePlusMetadata = maplibregl.StyleSpecification & { + metadata: { + thumbnail?: string; + }; + }; + type Props = { styles: maplibregl.StyleSpecification[]; + selectedStyleName?: string | undefined; map: maplibregl.Map | undefined; sourcesIdToReAdd: string[]; - switchToNewestStyle: boolean; }; - const { styles, map, sourcesIdToReAdd, switchToNewestStyle = false }: Props = $props(); + const { styles, selectedStyleName, map, sourcesIdToReAdd }: Props = $props(); let allStyles: MapLibreStylePlusMetadata[] | [] = $state([]); let selectedStyleUrl: string | undefined = $state(undefined); - let isClosed = $state(true); + // This variable is used for updating the prop selectedStyleName dynamically + let reactiveStyleSelection: MapLibreStylePlusMetadata | undefined = $state(undefined); let isOpen = $state(false); - let isFirstLoad = true; + // Get style info when styles are updated $effect(() => { if (styles.length > 0) { + // We do not await this to avoid complicating reactive logic fetchStyleInfo(); } else { allStyles = []; } }); - type MapLibreStylePlusMetadata = maplibregl.StyleSpecification & { - metadata: { - thumbnail?: string; - }; - }; + $effect(() => { + // Set initial selected style + reactiveStyleSelection = allStyles.find((style) => style.name === selectedStyleName) || allStyles[0]; + selectedStyleUrl = reactiveStyleSelection?.metadata?.thumbnail; + }); + + // Update the map when a new style is selected + $effect(() => { + if (reactiveStyleSelection) { + selectStyle(reactiveStyleSelection); + } + }); /** * Extract the raster thumbnail root tile, or return an empty string. @@ -105,9 +120,7 @@ map = new Map({ // Process the current map style const currentMapStyle = map?.getStyle(); if (currentMapStyle) { - const processedStyle = processStyle(currentMapStyle); - selectedStyleUrl = processedStyle?.metadata?.thumbnail || undefined; - processedStyles.push(processedStyle); + processedStyles.push(processStyle(currentMapStyle)); } // Process additional styles (download first if style is URL) @@ -121,77 +134,48 @@ map = new Map({ } } - // Filter out duplicate styles based on `name` field - const deduplicatedStyles = [...processedStyles].filter( + // Deduplicate styles by `name` + allStyles = processedStyles.filter( (style, index, self) => self.findIndex((s) => s.name === style.name) === index, ); - - // If a new style is added later, we automatically switch - // to that style, if switchToNewestStyle is true - if (switchToNewestStyle && !isFirstLoad) { - // Determine new styles only on subsequent updates - const newStyles = deduplicatedStyles.filter( - style => !allStyles.find(existingStyle => existingStyle.name === style.name) - ); - - // Handle new styles (e.g., auto-switch to the newest style) - if (newStyles.length > 0) { - selectStyle(newStyles[newStyles.length - 1]); - } - } - - // Update allStyles only if there are changes - if (JSON.stringify(allStyles) !== JSON.stringify(deduplicatedStyles)) { - allStyles = deduplicatedStyles; - } - - isFirstLoad = false; } function selectStyle(style: MapLibreStylePlusMetadata) { - // returns all the map style i.e. all layers, sources const currentMapStyle = map?.getStyle(); - // reAddLayers: user defined layers that needs to be preserved - const reAddLayers = currentMapStyle?.layers?.filter((layer) => { - return sourcesIdToReAdd?.includes(layer?.source); - }); - - // reAddSources: user defined sources that needs to be preserved - const reAddSources = Object?.entries(currentMapStyle?.sources) - ?.filter(([key]) => sourcesIdToReAdd?.includes(key)) - ?.map(([key, value]) => ({ [key]: value })); + if (style.name === currentMapStyle.name) return; selectedStyleUrl = style.metadata.thumbnail; - // changes to selected base layer (note: user defined layer and sources are lost) + // Apply the selected style to the map + // being sure to save sources and layers to add back on top + const reAddLayers = currentMapStyle?.layers?.filter((layer) => sourcesIdToReAdd.includes(layer.source)); + const reAddSources = Object.entries(currentMapStyle?.sources || {}) + .filter(([key]) => sourcesIdToReAdd.includes(key)) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + map?.setStyle(style); - isClosed = !isClosed; - - // reapply user defined source - if (reAddSources?.length > 0) { - for (const reAddSource of reAddSources) { - for (const [id, source] of Object.entries(reAddSource)) { - if (!map?.getStyle().sources[id]) { - map?.addSource(id, source); - } + + // Re-add sources and layers + if (reAddSources) { + Object.entries(reAddSources).forEach(([id, source]) => { + if (!map?.getStyle().sources[id]) { + map?.addSource(id, source); } - } + }); } - // reapply user defined layers - if (reAddLayers?.length > 0) { - for (const layer of reAddLayers) { - if (!map?.getStyle().layers.find((layerx) => layerx?.id === layer.id)) { + + if (reAddLayers) { + reAddLayers.forEach((layer) => { + if (!map?.getStyle().layers.find((l) => l.id === layer.id)) { map?.addLayer(layer); } - } + }); } } - onDestroy(() => { allStyles = []; selectedStyleUrl = undefined; - isClosed = true; }); diff --git a/src/mapper/src/lib/components/map/main.svelte b/src/mapper/src/lib/components/map/main.svelte index 5cae8a8d8a..59aa05b641 100644 --- a/src/mapper/src/lib/components/map/main.svelte +++ b/src/mapper/src/lib/components/map/main.svelte @@ -61,6 +61,7 @@ let map: maplibregl.Map | undefined = $state(); let loaded: boolean = $state(false); + let selectedBaselayer: string = $state('OSM'); let taskAreaClicked: boolean = $state(false); let toggleGeolocationStatus: boolean = $state(false); let projectSetupStep = $state(null); @@ -153,10 +154,6 @@ } }); - let taskAreaClicked: boolean = $state(false); - let toggleGeolocationStatus: boolean = $state(false); - let projectSetupStep = $state(null); - $effect(() => { projectSetupStep = +projectSetupStepStore.projectSetupStep; }); @@ -209,6 +206,7 @@ const offlineBasemapFile = await readFileFromOPFS(`${projectId}/basemap.pmtiles`); if (offlineBasemapFile) { await loadOfflinePmtiles(projectId); + selectedBaselayer = 'PMTiles'; } }); @@ -255,7 +253,7 @@ {map} styles={allBaseLayers} sourcesIdToReAdd={['tasks', 'entities', 'geolocation']} - switchToNewestStyle={true} + selectedStyleName={selectedBaselayer} > diff --git a/src/mapper/src/lib/fs/opfs.ts b/src/mapper/src/lib/fs/opfs.ts index a2e87c5409..16e4d68fd9 100644 --- a/src/mapper/src/lib/fs/opfs.ts +++ b/src/mapper/src/lib/fs/opfs.ts @@ -20,7 +20,7 @@ export async function readFileFromOPFS(filePath: string): Promise { if (!filename) { return null; // Invalid path } - console.log(`Getting OPFS file: ${filename}`); + // console.log(`Getting OPFS file: ${filename}`); const fileHandle = await currentDirectoryHandle.getFileHandle(filename); const fileData = await fileHandle.getFile(); // Read the file return fileData;