diff --git a/.env.example b/.env.example index d918631f7..b1a81d575 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ OSM_CLIENT_ID=${OSM_CLIENT_ID} OSM_CLIENT_SECRET=${OSM_CLIENT_SECRET} OSM_URL=${OSM_URL:-"https://www.openstreetmap.org"} OSM_SCOPE=${OSM_SCOPE:-'["read_prefs", "send_messages"]'} -OSM_LOGIN_REDIRECT_URI=${OSM_LOGIN_REDIRECT_URI:-"http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth"} +OSM_LOGIN_REDIRECT_URI=${OSM_LOGIN_REDIRECT_URI:-http${FMTM_DOMAIN:+s}://${FMTM_DOMAIN:-127.0.0.1:7051}/osmauth} OSM_SECRET_KEY=${OSM_SECRET_KEY} ### S3 File Storage ### diff --git a/src/frontend/e2e/auth.setup.ts b/src/frontend/e2e/auth.setup.ts index da517e29a..945e62e96 100644 --- a/src/frontend/e2e/auth.setup.ts +++ b/src/frontend/e2e/auth.setup.ts @@ -13,7 +13,7 @@ setup('authenticate', async ({ browserName, page }) => { // Note this sets a token so we can proceed, but the login will be // overwritten by svcfmtm localadmin user (as DEBUG=True) - await page.goto('/playwright-temp-login/'); + await page.goto('/playwright-temp-login'); // Now check we are signed in as localadmin await page.waitForSelector('text=localadmin'); diff --git a/src/frontend/src/routes.jsx b/src/frontend/src/routes.jsx index 72dd94081..51d27f0e6 100755 --- a/src/frontend/src/routes.jsx +++ b/src/frontend/src/routes.jsx @@ -179,7 +179,7 @@ const routes = createBrowserRouter([ ), }, { - path: '/osmauth/', + path: '/osmauth', element: ( Loading...}> @@ -189,7 +189,7 @@ const routes = createBrowserRouter([ ), }, { - path: '/playwright-temp-login/', + path: '/playwright-temp-login', element: ( Loading...}> diff --git a/src/mapper/package.json b/src/mapper/package.json index 9dea65f22..f1adb4bfb 100644 --- a/src/mapper/package.json +++ b/src/mapper/package.json @@ -52,7 +52,9 @@ "@turf/helpers": "^7.1.0", "drizzle-orm": "^0.35.3", "flatgeobuf": "^3.35.0", + "maplibre-gl": "^4.7.1", "pako": "^2.1.0", + "pmtiles": "^4.0.1", "svelte-maplibre": "^0.9.14", "uuid": "^11.0.2" }, diff --git a/src/mapper/pnpm-lock.yaml b/src/mapper/pnpm-lock.yaml index b781ef989..f6205ba9a 100644 --- a/src/mapper/pnpm-lock.yaml +++ b/src/mapper/pnpm-lock.yaml @@ -44,9 +44,15 @@ importers: flatgeobuf: specifier: ^3.35.0 version: 3.35.0 + maplibre-gl: + specifier: ^4.7.1 + version: 4.7.1 pako: specifier: ^2.1.0 version: 2.1.0 + pmtiles: + specifier: ^4.0.1 + version: 4.0.1 svelte-maplibre: specifier: ^0.9.14 version: 0.9.14(svelte@5.1.6) @@ -1974,6 +1980,9 @@ packages: pmtiles@3.2.1: resolution: {integrity: sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==} + pmtiles@4.0.1: + resolution: {integrity: sha512-YS4uj/v179kA6tDd2E8d7ikOIEHy8ahtXN+u4joZYGkG2EwTkC2DUwgNmYLipyl60Vzf3FnIccIuDZB6bmdKmg==} + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -4306,6 +4315,10 @@ snapshots: '@types/leaflet': 1.9.14 fflate: 0.8.2 + pmtiles@4.0.1: + dependencies: + fflate: 0.8.2 + postcss-load-config@3.1.4(postcss@8.4.47): dependencies: lilconfig: 2.1.0 diff --git a/src/mapper/src/assets/images/oam-logo.svg b/src/mapper/src/assets/images/oam-logo.svg new file mode 100644 index 000000000..c96a2549f --- /dev/null +++ b/src/mapper/src/assets/images/oam-logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/mapper/src/constants/baseLayers.ts b/src/mapper/src/constants/baseLayers.ts index 19708fede..1cb365852 100644 --- a/src/mapper/src/constants/baseLayers.ts +++ b/src/mapper/src/constants/baseLayers.ts @@ -1,3 +1,5 @@ +import oamLogo from '$assets/images/oam-logo.svg'; + export const osmStyle = { id: 'OSM Raster', version: 8, @@ -105,4 +107,31 @@ let satellite = { ], }; +export const customStyle = { + id: 'Custom', + version: 8, + name: 'Custom', + metadata: { + thumbnail: oamLogo, + }, + sources: { + custom: { + type: 'raster', + url: '', + tileSize: 512, + attribution: 'Protmaps', + }, + }, + layers: [ + { + id: 'custom', + type: 'raster', + source: 'custom', + layout: { + visibility: 'visible', + }, + }, + ], +}; + export const baseLayers = [stamenStyle, esriStyle, satellite]; diff --git a/src/mapper/src/lib/components/event-card.svelte b/src/mapper/src/lib/components/event-card.svelte deleted file mode 100644 index 4ad0b8fb9..000000000 --- a/src/mapper/src/lib/components/event-card.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - -
- {#if record?.profile_img} - Profile Picture - {:else} -
- -
- {/if} -
- - -
- - {record?.event} by {record.username || 'anon'} - - - -
-

#{record?.task_id}

-
- - {#if record?.created_at} - {@const formattedDate = formatDate(record.created_at)} - {formattedDate.date} - {formattedDate.time} - {:else} - No date available - {/if} -
-
-
- - -
{ e.stopPropagation(); handleZoomToTask() }}> - -
-
- - diff --git a/src/mapper/src/lib/components/map/layer-switcher.svelte b/src/mapper/src/lib/components/map/layer-switcher.svelte index 8cefa56d1..5b8afe0c4 100644 --- a/src/mapper/src/lib/components/map/layer-switcher.svelte +++ b/src/mapper/src/lib/components/map/layer-switcher.svelte @@ -84,7 +84,7 @@ map = new Map({ * Process the style to add metadata and return it. */ function processStyle(style: maplibregl.StyleSpecification): MapLibreStylePlusMetadata { - const thumbnailUrl = getRasterThumbnailUrl(style); + const thumbnailUrl = style?.metadata?.thumbnail || getRasterThumbnailUrl(style); return { ...style, metadata: { diff --git a/src/mapper/src/lib/components/map/main.svelte b/src/mapper/src/lib/components/map/main.svelte index 49923f066..fc9245706 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 000000000..37048fe78 --- /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/components/qrcode.svelte b/src/mapper/src/lib/components/qrcode.svelte new file mode 100644 index 000000000..081878ea5 --- /dev/null +++ b/src/mapper/src/lib/components/qrcode.svelte @@ -0,0 +1,68 @@ + + +
+ +
+
+ Scan this QR code in ODK Collect from another users phone, or download and import it manually + + { + if (infoDialogRef) infoDialogRef?.show(); + }} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter') { + if (infoDialogRef) infoDialogRef?.show(); + } + }} + role="button" + tabindex="0" + name="info-circle-fill" + class="!text-[14px] text-[#b91c1c] cursor-pointer duration-200 scale-[1.5]" + > + +
+
+ + +
+ +
+ + + downloadQrCode(projectName)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && downloadQrCode(projectName); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Download QR + + + {@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 000000000..167f07eab --- /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 000000000..a0839c39f --- /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 4abf82279..a6f71c41a 100644 --- a/src/mapper/src/routes/[projectId]/+page.svelte +++ b/src/mapper/src/routes/[projectId]/+page.svelte @@ -10,13 +10,14 @@ import ImportQrGif from '$assets/images/importQr.gif'; import SlTabGroup from '@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js'; - // import EventCard from '$lib/components/event-card.svelte'; + import SlDialog from '@shoelace-style/shoelace/dist/components/dialog/dialog.component.js'; 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'; - import { generateQrCode, downloadQrCode } from '$lib/utils/qrcode'; import { convertDateToTimeAgo } from '$lib/utils/datetime'; import { getTaskStore, getTaskEventStream } from '$store/tasks.svelte.ts'; import { @@ -38,9 +39,9 @@ let mapComponent: maplibregl.Map | undefined = $state(undefined); let tabGroup: SlTabGroup; - let selectedTab: string = $state('qrcode'); + let selectedTab: string = $state('map'); let isTaskActionModalOpen = $state(false); - let infoDialogRef: any = $state(null); + let infoDialogRef: SlDialog | null = $state(null); const taskStore = getTaskStore(); const taskEventStream = getTaskEventStream(data.projectId); @@ -58,8 +59,6 @@ // } // }); - let qrCodeData = $derived(generateQrCode(data.project.name, data.project.odk_token, 'REPLACE_ME_WITH_A_USERNAME')); - function zoomToTask(taskId: number) { const taskObj = data.project.tasks.find((task: ProjectTask) => task.id === taskId); @@ -131,7 +130,7 @@ projectOutlineCoords={data.project.outline.coordinates} projectId={data.projectId} entitiesUrl={data.project.data_extract_url} - /> + > + > {#if selectedTab !== 'map'} tabGroup.show('map')}> {#if selectedTab === 'events'} - - - zoomToTask(taskId)} /> + zoomToTask(taskId)} > {/if} {#if selectedTab === 'offline'} - Coming soon! + {/if} {#if selectedTab === 'qrcode'} -
- -
-
- Scan this QR code in ODK Collect from another users phone, or download and import it manually - - { - if (infoDialogRef) infoDialogRef?.show(); - }} - onkeydown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - if (infoDialogRef) infoDialogRef?.show(); - } - }} - role="button" - tabindex="0" - name="info-circle-fill" - class="!text-[14px] text-[#b91c1c] cursor-pointer duration-200 scale-[1.5]" - > - -
-
- - -
- -
- - - downloadQrCode(data?.project?.name)} - onkeydown={(e: KeyboardEvent) => { - e.key === 'Enter' && downloadQrCode(data?.project?.name); - }} - role="button" - tabindex="0" - size="small" - class="secondary w-full max-w-[200px]" - > - - Download QR - - + {#if +projectSetupStepStore.projectSetupStep !== projectSetupStepEnum['odk_project_load']} Open ODK {/if} -
+ {/if}
infoDialogRef.close()} + onclick={() => infoDialogRef?.close()} onkeydown={(e: KeyboardEvent) => { - e.key === 'Enter' && infoDialogRef.close(); + e.key === 'Enter' && infoDialogRef?.close(); }} role="button" tabindex="0" diff --git a/src/mapper/src/routes/[projectId]/+page.ts b/src/mapper/src/routes/[projectId]/+page.ts index ab2844dd4..258ef909e 100644 --- a/src/mapper/src/routes/[projectId]/+page.ts +++ b/src/mapper/src/routes/[projectId]/+page.ts @@ -5,7 +5,11 @@ const API_URL = import.meta.env.VITE_API_URL; export const load: PageLoad = async ({ parent, params, fetch }) => { // 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 d8e96a91a..ec5269091 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 000000000..b072eb097 --- /dev/null +++ b/src/mapper/static/assets/icons/arrow-clockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file