feat: improve offline tiles and add settings page
Some checks failed
TrafficCue CI / check (push) Failing after 1m0s
TrafficCue CI / build (push) Successful in 56s

This commit is contained in:
Cfp
2025-08-10 21:18:30 +02:00
parent 3bcd7cdade
commit 2fe1757866
7 changed files with 141 additions and 15 deletions

View File

@ -10,8 +10,9 @@
import { location } from "./location.svelte";
import { saved } from "$lib/saved.svelte";
import RoutingLayers from "$lib/services/navigation/RoutingLayers.svelte";
import { protocol } from "$lib/services/OfflineTiles";
import { getPMTilesURL, hasPMTiles, protocol } from "$lib/services/OfflineTiles";
import { layers, worldLayers } from "$lib/mapLayers";
import { PMTilesProtocol } from "svelte-maplibre-gl/pmtiles";
onMount(() => {
window.addEventListener("resize", map.updateMapPadding);
@ -28,8 +29,8 @@
</script>
<!-- <Protocol scheme="tiles" loadFn={protocol} /> -->
<!-- <PMTilesProtocol /> -->
<Protocol scheme="pmtiles" loadFn={protocol} />
<PMTilesProtocol />
<Protocol scheme="tiles" loadFn={protocol} />
<MapLibre
class="w-full h-full"
@ -47,7 +48,7 @@
// if(worldUrl) {
map.value!.addSource("ne2_shaded", { // TODO: rename to world
type: "vector",
url: "pmtiles://world",
url: await getPMTilesURL("world"),
attribution: "Natural Earth",
// maxzoom: 6
})
@ -61,7 +62,7 @@
// if(url) {
map.value!.addSource("openmaptiles", {
type: "vector",
url: "pmtiles://tiles"
url: await hasPMTiles("tiles") ? await getPMTilesURL("tiles") : "pmtiles://https://trafficcue-tiles.picoscratch.de/germany.pmtiles"
})

View File

@ -29,6 +29,9 @@
import InRouteSidebar from "./sidebar/InRouteSidebar.svelte";
import say from "$lib/services/navigation/TTS";
import { downloadPMTiles } from "$lib/services/OfflineTiles";
import SettingsSidebar from "./sidebar/settings/SettingsSidebar.svelte";
import AboutSidebar from "./sidebar/settings/AboutSidebar.svelte";
import OfflineMapsSidebar from "./sidebar/settings/OfflineMapsSidebar.svelte";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const views: Record<string, Component<any>> = {
@ -38,6 +41,9 @@
trip: TripSidebar,
search: SearchSidebar,
user: UserSidebar,
settings: SettingsSidebar,
about: AboutSidebar,
"offline-maps": OfflineMapsSidebar
};
let isDragging = false;
@ -156,7 +162,9 @@
<UserIcon />
</button>
</RequiresCapability>
<button>
<button onclick={() => {
view.switch("settings");
}}>
<SettingsIcon />
</button>
<!-- <button onclick={() => {

View File

@ -0,0 +1,15 @@
<script>
import SidebarHeader from "../SidebarHeader.svelte";
</script>
<SidebarHeader>
About
</SidebarHeader>
<h1 style="font-size: 2em; font-weight: bold;">TrafficCue</h1>
<span>Powered by:</span>
<ul>
<li>© OpenStreetMap contributors</li>
<li>Natural Earth</li>
<li>MapLibre</li>
</ul>

View File

@ -0,0 +1,32 @@
<script>
import { downloadPMTiles, getRemoteList } from "$lib/services/OfflineTiles";
import { DownloadCloudIcon } from "@lucide/svelte";
import SettingsButton from "./SettingsButton.svelte";
import SidebarHeader from "../SidebarHeader.svelte";
</script>
<SidebarHeader>
Offline Maps
</SidebarHeader>
{#await getRemoteList()}
<p>Loading...</p>
{:then list}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#if list.length === 0}
<p>No offline maps available.</p>
{/if}
{#if !window.__TAURI__}
<p>Offline maps are only available on mobile.</p>
{/if}
{#each list as item, _index (item.file)}
<SettingsButton disabled={!window.__TAURI__} icon={DownloadCloudIcon} text={item.name} onclick={async () => {
await downloadPMTiles("https://trafficcue-tiles.picoscratch.de/" + item.file, item.name.includes("World") ? "world": "tiles");
alert(`Downloaded ${item.name}`);
location.reload();
}} />
{/each}
</div>
{/await}

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import type { IconProps } from "@lucide/svelte";
import type { Component } from "svelte";
import { view } from "../../view.svelte";
const { icon: Icon, text, view: viewName, onclick, disabled }: { icon: Component<IconProps>, text: string, view?: string, onclick?: () => void, disabled?: boolean } = $props();
</script>
<Button variant="secondary" style="width: 100%; height: 40px;" {disabled} onclick={() => {
if(viewName) view.switch(viewName)
if(onclick) onclick();
}}>
<Icon />
{text}
</Button>

View File

@ -0,0 +1,42 @@
<script>
import { CodeIcon, InfoIcon, LanguagesIcon, MapIcon, PaintbrushIcon } from "@lucide/svelte";
import SidebarHeader from "../SidebarHeader.svelte";
import SettingsButton from "./SettingsButton.svelte";
</script>
<SidebarHeader>
Settings
</SidebarHeader>
<div id="sections">
<section>
<h2>General</h2>
<SettingsButton icon={LanguagesIcon} text="Language" disabled />
</section>
<section>
<h2>Map</h2>
<SettingsButton icon={MapIcon} text="Offline Maps" view="offline-maps" />
<SettingsButton icon={PaintbrushIcon} text="Map Style" disabled />
</section>
<section>
<h2>About</h2>
<SettingsButton icon={CodeIcon} text="Developer Settings" disabled />
<SettingsButton icon={InfoIcon} text="About" view="about" />
</section>
</div>
<style>
section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -37,7 +37,15 @@ export async function downloadPMTiles(url: string, name: string): Promise<void>
console.log(`Download completed: ${path}`);
}
export async function getRemoteList(): Promise<{ name: string, file: string }[]> {
return await fetch("https://trafficcue-tiles.picoscratch.de/index.json").then(res => res.json());
}
export async function hasPMTiles(name: string): Promise<boolean> {
if(!window.__TAURI__) {
return false; // Tauri environment is not available
}
const filename = name + ".pmtiles";
const baseDir = BaseDirectory.AppData;
const appDataDirPath = await appDataDir();
@ -50,23 +58,27 @@ export async function hasPMTiles(name: string): Promise<boolean> {
return await exists(filePath, { baseDir });
}
export async function getPMTiles(name: string) {
export async function getPMTilesURL(name: string) {
if(!window.__TAURI__) {
return `pmtiles://https://trafficcue-tiles.picoscratch.de/${name}.pmtiles`;
}
const filename = name + ".pmtiles";
const baseDir = BaseDirectory.AppData;
const appDataDirPath = await appDataDir();
if(!await exists(appDataDirPath)) {
throw new Error("App data directory does not exist.");
return `pmtiles://https://trafficcue-tiles.picoscratch.de/${name}.pmtiles`;
// throw new Error("App data directory does not exist.");
}
const filePath = await join(appDataDirPath, filename);
if(!await exists(filePath, { baseDir })) {
throw new Error(`PMTiles file not found: ${filePath}`);
return `pmtiles://https://trafficcue-tiles.picoscratch.de/${name}.pmtiles`;
// throw new Error(`PMTiles file not found: ${filePath}`);
}
return `asset:/${filename}`;
// return convertFileSrc(filePath);
return `tiles://${name}`;
}
async function readBytes(name: string, offset: number, length: number): Promise<Uint8Array> {
@ -152,7 +164,7 @@ export class Protocol {
abortController: AbortController
) => {
if (params.type === "json") {
const pmtilesUrl = params.url.substr(10);
const pmtilesUrl = params.url.substr(8);
let instance = this.tiles.get(pmtilesUrl);
if (!instance) {
instance = new PMTiles(new FSSource(pmtilesUrl));
@ -182,10 +194,10 @@ export class Protocol {
},
};
}
const re = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/);
const re = new RegExp(/tiles:\/\/(.+)/);
const result = params.url.match(re);
if (!result) {
throw new Error("Invalid PMTiles protocol URL");
throw new Error("Invalid Tiles protocol URL");
}
const pmtilesUrl = result[1];