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 @@
+
+
+
+
+
+
+
+
+ {#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