From ebd2f94bda399fff30f4146f319bf53270d379cc Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Wed, 27 Nov 2024 18:04:22 +0000 Subject: [PATCH] feat: add basemap component for handling download + opfs pmtiles --- src/mapper/src/lib/components/map/main.svelte | 51 ++++++- .../lib/components/offline/basemaps.svelte | 126 ++++++++++++++++++ src/mapper/src/lib/fs/opfs.ts | 73 ++++++++++ src/mapper/src/lib/utils/basemaps.ts | 125 +++++++++++++++++ .../src/routes/[projectId]/+page.svelte | 3 +- src/mapper/src/routes/[projectId]/+page.ts | 14 +- src/mapper/src/store/common.svelte.ts | 43 +++++- .../static/assets/icons/arrow-clockwise.svg | 4 + 8 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 src/mapper/src/lib/components/offline/basemaps.svelte create mode 100644 src/mapper/src/lib/fs/opfs.ts create mode 100644 src/mapper/src/lib/utils/basemaps.ts create mode 100644 src/mapper/static/assets/icons/arrow-clockwise.svg diff --git a/src/mapper/src/lib/components/map/main.svelte b/src/mapper/src/lib/components/map/main.svelte index 49923f0661..fc9245706b 100644 --- a/src/mapper/src/lib/components/map/main.svelte +++ b/src/mapper/src/lib/components/map/main.svelte @@ -15,6 +15,8 @@ ControlGroup, ControlButton, } from 'svelte-maplibre'; + import maplibre from 'maplibre-gl'; + import { Protocol } from 'pmtiles'; import { polygon } from '@turf/helpers'; import { buffer } from '@turf/buffer'; import { bbox } from '@turf/bbox'; @@ -30,10 +32,10 @@ import Geolocation from '$lib/components/map/geolocation.svelte'; import FlatGeobuf from '$lib/components/map/flatgeobuf-layer.svelte'; import { getTaskStore } from '$store/tasks.svelte.ts'; - import { getProjectSetupStepStore } from '$store/common.svelte.ts'; + import { getProjectSetupStepStore, getProjectBasemapStore } from '$store/common.svelte.ts'; // import { entityFeatcolStore, selectedEntityId } from '$store/entities'; import { projectSetupStep as projectSetupStepEnum } from '$constants/enums.ts'; - import { baseLayers, osmStyle } from '$constants/baseLayers.ts'; + import { baseLayers, osmStyle, customStyle } from '$constants/baseLayers.ts'; type bboxType = [number, number, number, number]; @@ -49,12 +51,31 @@ const taskStore = getTaskStore(); const projectSetupStepStore = getProjectSetupStepStore(); + const projectBasemapStore = getProjectBasemapStore(); let map: maplibregl.Map | undefined = $state(); let loaded: boolean = $state(false); let taskAreaClicked: boolean = $state(false); let toggleGeolocationStatus: boolean = $state(false); let projectSetupStep = $state(null); + // If no custom layer URL, omit, else set URL from projectPmtilesUrl + let processedBaseLayers = $derived( + [ + ...baseLayers, + ...(projectBasemapStore.projectPmtilesUrl + ? [{ + ...customStyle, + sources: { + ...customStyle.sources, + custom: { + ...customStyle.sources.custom, + url: projectBasemapStore.projectPmtilesUrl, + }, + }, + }] + : []), + ] + ); $effect(() => { projectSetupStep = +projectSetupStepStore.projectSetupStep; @@ -64,6 +85,11 @@ $effect(() => { if (map) { setMapRef(map); + // Register pmtiles protocol + if (!maplibre.config.REGISTERED_PROTOCOLS.hasOwnProperty('pmtiles')) { + let protocol = new Protocol(); + maplibre.addProtocol('pmtiles', protocol.tile); + } } }); @@ -103,6 +129,7 @@ { id: 'locationDot', url: LocationDotImg }, ]} > + @@ -115,6 +142,10 @@ > + + + + {#if toggleGeolocationStatus} @@ -191,10 +222,18 @@ manageHoverState /> - - - - + + + + + {#if projectSetupStep === projectSetupStepEnum['task_selection']}

please select a task / feature for mapping

diff --git a/src/mapper/src/lib/components/offline/basemaps.svelte b/src/mapper/src/lib/components/offline/basemaps.svelte new file mode 100644 index 0000000000..37048fe78a --- /dev/null +++ b/src/mapper/src/lib/components/offline/basemaps.svelte @@ -0,0 +1,126 @@ + + +
+ +
+
+ Manage Basemaps +
+
+ + +
+ {#if basemapsAvailable} + + { + const selection = event.originalTarget.value + selectedBasemapId = selection; + }} + > + {#each basemapStore.projectBasemaps as basemap} + {#if basemap.status === "SUCCESS"} + + {basemap.tile_source} {basemap.format} + + {/if} + {/each} + + {:else} +
+
+ There are no basemaps available for this project. +
+
+ Please ask the project manager to create basemaps. +
+
+ {/if} +
+ + + {#if selectedBasemap?.format === 'pmtiles' } + loadOnlinePmtiles(selectedBasemap)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && loadOnlinePmtiles(selectedBasemap); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Show On Map + + + writeOfflinePmtiles(projectId, selectedBasemapId)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && writeOfflinePmtiles(projectId, selectedBasemapId); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Store Offline + + + + {:else if selectedBasemap?.format === 'mbtiles' } + downloadMbtiles(projectId, selectedBasemapId)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && downloadMbtiles(projectId, selectedBasemapId); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Download MBTiles + + {/if} + + {@render children?.()} +
diff --git a/src/mapper/src/lib/fs/opfs.ts b/src/mapper/src/lib/fs/opfs.ts new file mode 100644 index 0000000000..167f07eab2 --- /dev/null +++ b/src/mapper/src/lib/fs/opfs.ts @@ -0,0 +1,73 @@ +export async function readFileFromOPFS(filePath: string): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + const directories = filePath.split('/'); + + let currentDirectoryHandle: FileSystemDirectoryHandle = opfsRoot; + + // Iterate dirs and get directory handles + 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 the final directory handle + try { + const filename = directories.pop(); + if (!filename) { + return null; // Invalid path + } + 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: Blob | BufferSource | string): Promise { + 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 = directories.pop(); + if (!filename) { + throw new Error('Invalid file path'); // Ensure filename exists + } + + // Start with the root directory handle + let currentDirectoryHandle: FileSystemDirectoryHandle = 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); + throw 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.error('Error writing to file:', error); + throw error; + } + + // Close the writable stream + await writable.close(); + console.log(`Finished write to OPFS file: ${filePath}`); +} diff --git a/src/mapper/src/lib/utils/basemaps.ts b/src/mapper/src/lib/utils/basemaps.ts new file mode 100644 index 0000000000..a0839c39f4 --- /dev/null +++ b/src/mapper/src/lib/utils/basemaps.ts @@ -0,0 +1,125 @@ +import type { UUID } from 'crypto'; + +import { getAlertStore, getProjectBasemapStore } from '$store/common.svelte.ts'; +import { readFileFromOPFS, writeBinaryToOPFS } from '$lib/fs/opfs'; + +export interface Basemap { + id: UUID; + url: string; + tile_source: string; + status: string; + created_at: string; + format: string; + mimetype: string; +} + +const API_URL = import.meta.env.VITE_API_URL; +const alertStore = getAlertStore(); +const basemapStore = getProjectBasemapStore(); + +export async function getBasemapList(projectId: number): Promise { + try { + const basemapsResponse = await fetch(`${API_URL}/projects/${projectId}/tiles`, { + credentials: 'include', + }); + + if (!basemapsResponse.ok) { + throw new Error('Failed to fetch basemaps'); + } + + return await basemapsResponse.json(); + } catch (error) { + console.error('Error refreshing basemaps:', error); + alertStore.setAlert({ + variant: 'danger', + message: 'Error fetching basemaps list.', + }); + return []; + } +} + +type BasemapDownload = { + data: ArrayBuffer; + headers: Headers | null; +}; + +async function downloadBasemap(projectId: number, basemapId: UUID): Promise { + try { + const downloadResponse = await fetch(`${API_URL}/projects/${projectId}/tiles/${basemapId}`, { + credentials: 'include', + }); + + if (!downloadResponse.ok) { + throw new Error('Failed to download mbtiles'); + } + + let basemapData = await downloadResponse.arrayBuffer(); + if (!basemapData) { + throw new Error('Basemap contained no data'); + } + + return { + data: basemapData, + headers: downloadResponse.headers, + }; + } catch (error) { + console.error('Error downloading basemaps:', error); + alertStore.setAlert({ + variant: 'danger', + message: 'Error downloading basemap file.', + }); + return { data: new ArrayBuffer(0), headers: null }; + } +} + +export async function downloadMbtiles(projectId: number, basemapId: UUID | null) { + if (!basemapId) return; + + const { data, headers } = await downloadBasemap(projectId, basemapId); + const filename = headers?.get('content-disposition')?.split('filename=')[1] || 'basemap.mbtiles'; + const contentType = headers?.get('content-type') || 'application/vnd.mapbox-vector-tile'; + + // Create Blob from ArrayBuffer + const blob = new Blob([data], { type: contentType }); + 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); +} + +export async function loadOnlinePmtiles(selectedBasemap: Basemap) { + const pmtilesUrl = `pmtiles://${selectedBasemap.url}`; + basemapStore.setProjectPmtilesUrl(pmtilesUrl); +} + +export async function loadOfflinePmtiles(projectId: number) { + const filePath = `${projectId}/all.pmtiles`; + + // Read file from OPFS and display on map + const opfsPmtilesData = await readFileFromOPFS(filePath); + // FIXME perhaps add error or warning here + if (!opfsPmtilesData) return; + + // Create URL from OPFS file (must start with pmtiles://) + const pmtilesUrl = `pmtiles://${URL.createObjectURL(opfsPmtilesData)}`; + // URL.revokeObjectURL(pmtilesUrl); + + basemapStore.setProjectPmtilesUrl(pmtilesUrl); +} + +export async function writeOfflinePmtiles(projectId: number, basemapId: UUID | null) { + if (!basemapId) return; + + const { data } = await downloadBasemap(projectId, basemapId); + + // Copy to OPFS filesystem for offline use + const filePath = `${projectId}/all.pmtiles`; + await writeBinaryToOPFS(filePath, data); + + await loadOfflinePmtiles(projectId); +} diff --git a/src/mapper/src/routes/[projectId]/+page.svelte b/src/mapper/src/routes/[projectId]/+page.svelte index 2a0bce35d9..a6f71c41a3 100644 --- a/src/mapper/src/routes/[projectId]/+page.svelte +++ b/src/mapper/src/routes/[projectId]/+page.svelte @@ -14,6 +14,7 @@ import BottomSheet from '$lib/components/bottom-sheet.svelte'; import MapComponent from '$lib/components/map/main.svelte'; import QRCodeComponent from '$lib/components/qrcode.svelte'; + import BasemapComponent from '$lib/components/offline/basemaps.svelte'; import DialogTaskActions from '$lib/components/dialog-task-actions.svelte'; import type { ProjectTask, ZoomToTaskEventDetail } from '$lib/types'; @@ -146,7 +147,7 @@ zoomToTask(taskId)} > {/if} {#if selectedTab === 'offline'} - Coming soon! + {/if} {#if selectedTab === 'qrcode'} { // const { db } = await parent(); + const { projectId } = params; + /* + Login + user details + */ const userResponse = await fetch(`${API_URL}/auth/refresh`, { credentials: 'include' }); if (userResponse.status === 401) { // TODO redirect to different error page to handle login @@ -13,7 +17,9 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { } const userObj = await userResponse.json(); - const { projectId } = params; + /* + Project details + */ const projectResponse = await fetch(`${API_URL}/projects/${projectId}`, { credentials: 'include' }); if (projectResponse.status === 401) { // TODO redirect to different error page to handle login @@ -23,6 +29,12 @@ export const load: PageLoad = async ({ parent, params, fetch }) => { throw error(404, { message: `Project with ID (${projectId}) not found` }); } + /* + Basemaps + */ + // Load existing OPFS PMTiles archive if present + // TODO + return { project: await projectResponse.json(), projectId: parseInt(projectId), diff --git a/src/mapper/src/store/common.svelte.ts b/src/mapper/src/store/common.svelte.ts index d8e96a91a8..ec52690918 100644 --- a/src/mapper/src/store/common.svelte.ts +++ b/src/mapper/src/store/common.svelte.ts @@ -1,3 +1,6 @@ +import type { Basemap } from '$lib/utils/basemaps'; +import { getBasemapList } from '$lib/utils/basemaps'; + interface AlertDetails { variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' | null; message: string; @@ -5,6 +8,8 @@ interface AlertDetails { let alert: AlertDetails | undefined = $state({ variant: null, message: '' }); let projectSetupStep: number | null = $state(null); +let projectBasemaps: Basemap[] = $state([]); +let projectPmtilesUrl: string | null = $state(null); function getAlertStore() { return { @@ -22,8 +27,42 @@ function getProjectSetupStepStore() { get projectSetupStep() { return projectSetupStep; }, - setProjectSetupStep: (step: string) => (projectSetupStep = step), + setProjectSetupStep: (step: number) => (projectSetupStep = step), + }; +} + +function getProjectBasemapStore() { + async function refreshBasemaps(projectId: number) { + const basemaps = await getBasemapList(projectId); + setProjectBasemaps(basemaps); + } + + function setProjectBasemaps(basemapArray: Basemap[]) { + // First we sort by recent first, created_at string datetime key + const sortedBasemaps = basemapArray.sort((a, b) => { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + projectBasemaps = sortedBasemaps; + } + + return { + get projectBasemaps() { + return projectBasemaps; + }, + setProjectBasemaps: setProjectBasemaps, + refreshBasemaps: refreshBasemaps, + + get projectPmtilesUrl() { + return projectPmtilesUrl; + }, + setProjectPmtilesUrl: (url: string) => { + projectPmtilesUrl = url; + getAlertStore().setAlert({ + variant: 'success', + message: 'Success! Check the base layer selector.', + }); + }, }; } -export { getAlertStore, getProjectSetupStepStore }; +export { getAlertStore, getProjectSetupStepStore, getProjectBasemapStore }; diff --git a/src/mapper/static/assets/icons/arrow-clockwise.svg b/src/mapper/static/assets/icons/arrow-clockwise.svg new file mode 100644 index 0000000000..b072eb097a --- /dev/null +++ b/src/mapper/static/assets/icons/arrow-clockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file