Skip to content

Commit

Permalink
feat: add basemap component for handling download + opfs pmtiles
Browse files Browse the repository at this point in the history
  • Loading branch information
spwoodcock committed Nov 27, 2024
1 parent 598619d commit ebd2f94
Show file tree
Hide file tree
Showing 8 changed files with 429 additions and 10 deletions.
51 changes: 45 additions & 6 deletions src/mapper/src/lib/components/map/main.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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);
}
}
});
Expand Down Expand Up @@ -103,6 +129,7 @@
{ id: 'locationDot', url: LocationDotImg },
]}
>
<!-- Controls -->
<NavigationControl position="top-left" />
<ScaleControl />
<Control class="flex flex-col gap-y-2" position="top-left">
Expand All @@ -115,6 +142,10 @@
>
</ControlGroup></Control
>
<Control class="flex flex-col gap-y-2" position="bottom-right">
<LayerSwitcher {map} extraStyles={processedBaseLayers} sourcesIdToReAdd={['tasks', 'entities', 'geolocation']} />
<Legend />
</Control>
<!-- Add the Geolocation GeoJSON layer to the map -->
{#if toggleGeolocationStatus}
<Geolocation bind:map bind:toggleGeolocationStatus></Geolocation>
Expand Down Expand Up @@ -191,10 +222,18 @@
manageHoverState
/>
</FlatGeobuf>
<Control class="flex flex-col gap-y-2" position="bottom-right">
<LayerSwitcher {map} extraStyles={baseLayers} sourcesIdToReAdd={['tasks', 'entities', 'geolocation']} />
<Legend />
</Control>

<!-- Offline pmtiles, if present (alternative approach, not baselayer) -->
<!-- {#if projectBasemapStore.projectPmtilesUrl}
<RasterTileSource
url={projectBasemapStore.projectPmtilesUrl}
tileSize={512}
>
<RasterLayer id="pmtile-basemap" paint={{'raster-opacity': 0.8}}></RasterLayer>
</RasterTileSource>
{/if} -->

<!-- Help text for user on first load -->
{#if projectSetupStep === projectSetupStepEnum['task_selection']}
<div class="absolute top-5 w-fit bg-[#F097334D] z-10 left-[50%] translate-x-[-50%] p-1">
<p class="uppercase font-barlow-medium text-base">please select a task / feature for mapping</p>
Expand Down
126 changes: 126 additions & 0 deletions src/mapper/src/lib/components/offline/basemaps.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import type { UUID } from 'crypto';
import type { SlSelectEvent } from '@shoelace-style/shoelace/dist/events';
// FIXME this is a workaround to re-import, as using hot-select
// and hot-option prevents selection of values!
// TODO should raise an issue in hotosm/ui about this / test further
import '@shoelace-style/shoelace/dist/components/select/select.js';
import '@shoelace-style/shoelace/dist/components/option/option.js';
import type { Basemap } from '$lib/utils/basemaps';
import { getProjectBasemapStore } from '$store/common.svelte.ts';
import { downloadMbtiles, loadOnlinePmtiles, writeOfflinePmtiles } from '$lib/utils/basemaps';
interface Props {
projectId: number;
children?: Snippet;
}
let { projectId, children }: Props = $props();
const basemapStore = getProjectBasemapStore();
let selectedBasemapId: UUID | null = $state(null);
// Reactive variables
let basemapsAvailable: boolean = $derived(basemapStore.projectBasemaps && basemapStore.projectBasemaps.length > 0);
let selectedBasemap: Basemap | null = $derived(basemapStore.projectBasemaps?.find((basemap: Basemap) => basemap.id === selectedBasemapId) || null);
onMount(() => {
basemapStore.refreshBasemaps(projectId);
});
</script>

<div class="flex flex-col items-center p-4 space-y-4">
<!-- Text above the basemap selector -->
<div class="text-center w-full">
<div class="font-bold text-lg font-barlow-medium">
<span class="mr-1">Manage Basemaps</span>
</div>
</div>

<!-- Basemap selector -->
<div class="flex justify-center w-full max-w-sm">
{#if basemapsAvailable}
<!-- Note here we cannot two way bind:var to the web-component,
so use event instead -->
<sl-select
placeholder="Select a basemap"
onsl-change={(event: SlSelectEvent) => {
const selection = event.originalTarget.value
selectedBasemapId = selection;
}}
>
{#each basemapStore.projectBasemaps as basemap}
{#if basemap.status === "SUCCESS"}
<sl-option value={basemap.id}>
{basemap.tile_source} {basemap.format}
</sl-option>
{/if}
{/each}
</sl-select>
{:else}
<div class="text-center w-full">
<div class="text-sm font-barlow-medium">
There are no basemaps available for this project.
</div>
<div class="text-sm font-barlow-medium pt-2">
Please ask the project manager to create basemaps.
</div>
</div>
{/if}
</div>

<!-- Load baselayer & download to OPFS buttons -->
{#if selectedBasemap?.format === 'pmtiles' }
<sl-button
onclick={() => loadOnlinePmtiles(selectedBasemap)}
onkeydown={(e: KeyboardEvent) => {
e.key === 'Enter' && loadOnlinePmtiles(selectedBasemap);
}}
role="button"
tabindex="0"
size="small"
class="secondary w-full max-w-[200px]"
>
<hot-icon slot="prefix" name="download" class="!text-[1rem] text-[#b91c1c] cursor-pointer duration-200"
></hot-icon>
<span class="font-barlow-medium text-base uppercase">Show On Map</span>
</sl-button>

<sl-button
onclick={() => 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]"
>
<hot-icon slot="prefix" name="download" class="!text-[1rem] text-[#b91c1c] cursor-pointer duration-200"
></hot-icon>
<span class="font-barlow-medium text-base uppercase">Store Offline</span>
</sl-button>

<!-- Download Mbtiles Button -->
{:else if selectedBasemap?.format === 'mbtiles' }
<sl-button
onclick={() => 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]"
>
<hot-icon slot="prefix" name="download" class="!text-[1rem] text-[#b91c1c] cursor-pointer duration-200"
></hot-icon>
<span class="font-barlow-medium text-base uppercase">Download MBTiles</span>
</sl-button>
{/if}

{@render children?.()}
</div>
73 changes: 73 additions & 0 deletions src/mapper/src/lib/fs/opfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
export async function readFileFromOPFS(filePath: string): Promise<File | null> {
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<void> {
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}`);
}
Loading

0 comments on commit ebd2f94

Please sign in to comment.