feat: get nearby POIs
All checks were successful
TrafficCue CI / check (push) Successful in 57s
TrafficCue CI / build (push) Successful in 1m29s
TrafficCue CI / build-android (push) Successful in 17m31s

This commit is contained in:
2025-09-13 13:18:53 +02:00
parent a48ed785f5
commit 3d7dacba9b
5 changed files with 172 additions and 3 deletions

View File

@ -5,7 +5,8 @@
"home": "Heim", "home": "Heim",
"school": "Schule", "school": "Schule",
"work": "Arbeit", "work": "Arbeit",
"no-location": "Kein {name} Speicherort gespeichert." "no-location": "Kein {name} Speicherort gespeichert.",
"saved-routes": "Gespeicherte Routen"
}, },
"location": { "location": {
"unlock": "Standort entsperren", "unlock": "Standort entsperren",
@ -136,6 +137,11 @@
"description": "TrafficCue ist auf Android verfügbar. Holen Sie es sich für das beste Erlebnis!", "description": "TrafficCue ist auf Android verfügbar. Holen Sie es sich für das beste Erlebnis!",
"button": "Runterladen" "button": "Runterladen"
} }
},
"nearby-poi": {
"header": "POIs in der Nähe",
"no-poi": "Keine POIs in der Nähe gefunden.",
"loading": "POIs in der Nähe werden geladen …"
} }
}, },
"location-selector": { "location-selector": {
@ -143,5 +149,10 @@
"searching": "Suchen...", "searching": "Suchen...",
"no-results": "Keine Orte gefunden." "no-results": "Keine Orte gefunden."
}, },
"unsave": "Löschen" "unsave": "Löschen",
"unnamed": "Unbenannt",
"poi": {
"fuel": "Tankstelle",
"parking": "Parken"
}
} }

View File

@ -137,6 +137,11 @@
"description": "TrafficCue is available on Android. Get it for the best experience!", "description": "TrafficCue is available on Android. Get it for the best experience!",
"button": "Get it" "button": "Get it"
} }
},
"nearby-poi": {
"header": "Nearby Points of Interest",
"no-poi": "No points of interest found nearby.",
"loading": "Loading nearby points of interest..."
} }
}, },
"unsave": "Unsave", "unsave": "Unsave",
@ -144,5 +149,10 @@
"current": "Current Location", "current": "Current Location",
"searching": "Searching...", "searching": "Searching...",
"no-results": "No locations found." "no-results": "No locations found."
},
"unnamed": "Unnamed",
"poi": {
"fuel": "Fuel Station",
"parking": "Parking"
} }
} }

View File

@ -39,6 +39,7 @@
language: "settings/LanguageSidebar", language: "settings/LanguageSidebar",
onboarding: "onboarding/OnboardingSidebar", onboarding: "onboarding/OnboardingSidebar",
"onboarding-vehicles": "onboarding/OnboardingVehiclesSidebar", "onboarding-vehicles": "onboarding/OnboardingVehiclesSidebar",
"nearby-poi": "NearbyPOISidebar",
}; };
const fullscreen: Record<string, boolean> = { const fullscreen: Record<string, boolean> = {
@ -55,6 +56,7 @@
language: true, language: true,
onboarding: true, onboarding: true,
"onboarding-vehicles": true, "onboarding-vehicles": true,
"nearby-poi": false,
}; };
let isDragging = false; let isDragging = false;
@ -62,7 +64,7 @@
let startHeight = 0; let startHeight = 0;
let sidebarHeight = new Tween(200, { let sidebarHeight = new Tween(200, {
duration: 500, duration: 500,
easing: quintOut easing: quintOut,
}); });
let lastSidebarHeight = 0; let lastSidebarHeight = 0;
$effect(() => { $effect(() => {

View File

@ -2,7 +2,9 @@
import { import {
BriefcaseIcon, BriefcaseIcon,
DownloadIcon, DownloadIcon,
FuelIcon,
HomeIcon, HomeIcon,
ParkingSquareIcon,
SchoolIcon, SchoolIcon,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
@ -104,6 +106,40 @@
<VehicleSelector /> <VehicleSelector />
<div
style="display: flex; gap: 0.5rem; justify-content: space-evenly;"
class="mt-2"
>
<Button
variant="secondary"
size="lg"
style="flex: 1;"
onclick={() => {
view.switch("nearby-poi", {
tags: "amenity=fuel",
});
}}
>
<FuelIcon />
<!-- TODO: hide if no space -->
{m["poi.fuel"]()}
</Button>
<Button
variant="secondary"
size="lg"
style="flex: 1;"
onclick={() => {
view.switch("nearby-poi", {
tags: "amenity=parking",
});
}}
>
<ParkingSquareIcon />
<!-- TODO: hide if no space -->
{m["poi.parking"]()}
</Button>
</div>
{#if !window.__TAURI__} {#if !window.__TAURI__}
<Card.Root style="margin-top: 1rem;"> <Card.Root style="margin-top: 1rem;">
<Card.Header> <Card.Header>

View File

@ -0,0 +1,110 @@
<script lang="ts">
import { m } from "$lang/messages";
import Button from "$lib/components/ui/button/button.svelte";
import { fetchNearbyPOI, type OverpassElement } from "$lib/services/Overpass";
import { location } from "../location.svelte";
import { map, pin } from "../map.svelte";
import SidebarHeader from "./SidebarHeader.svelte";
let { tags }: { tags?: string } = $props();
let pois: OverpassElement[] = $state([]);
let loading = $state(true);
$effect(() => {
if (!tags) {
loading = false;
pois = [];
return;
}
fetchNearbyPOI(location.lat, location.lng, tags.split(","), 500).then(
(results) => {
pois = results.elements.sort((a, b) => {
const distA = distanceTo(
a.lat ?? a.center?.lat ?? 0.0,
a.lon ?? a.center?.lon ?? 0.0,
);
const distB = distanceTo(
b.lat ?? b.center?.lat ?? 0.0,
b.lon ?? b.center?.lon ?? 0.0,
);
return distA - distB;
});
loading = false;
},
);
});
// returns meters
function distanceTo(lat: number, lon: number) {
const R = 6371e3; // metres
const φ1 = (location.lat * Math.PI) / 180; // φ, λ in radians
const φ2 = (lat * Math.PI) / 180;
const Δφ = ((lat - location.lat) * Math.PI) / 180;
const Δλ = ((lon - location.lng) * Math.PI) / 180;
const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // in metres
}
</script>
<SidebarHeader>
{#if tags}
{tags.split(",")[0].split("=")[1]?.replace(/_/g, " ") ?? "POIs"}
{:else}
{m["sidebar.nearby-poi.header"]()}
{/if}
</SidebarHeader>
{#if loading}
<div class="text-sm text-muted-foreground">{m.loading()}</div>
{:else}
<div class="flex flex-col gap-2 p-2">
{#if pois.length === 0}
<div class="text-sm text-muted-foreground">
{m["sidebar.nearby-poi.no-poi"]()}
</div>
{/if}
{#each pois as poi (poi.id)}
<Button
variant="secondary"
class="w-full flex flex-col items-start h-auto"
onclick={() => {
pin.dropPin(
poi.lat ?? poi.center?.lat ?? 0.0,
poi.lon ?? poi.center?.lon ?? 0.0,
);
pin.showInfo();
map.value?.flyTo({
center: [
poi.lon ?? poi.center?.lon ?? 0.0,
poi.lat ?? poi.center?.lat ?? 0.0,
],
zoom: 19,
});
}}
>
<div class="font-bold">
{poi.tags.name ?? poi.tags.brand ?? m.unnamed()}
</div>
<div class="text-sm">
{#if poi.tags.amenity}
<span class="capitalize">{poi.tags.amenity}</span>
{/if}
<span>
{Math.round(
distanceTo(
poi.lat ?? poi.center?.lat ?? 0.0,
poi.lon ?? poi.center?.lon ?? 0.0,
),
)}m
</span>
</div>
</Button>
{/each}
</div>
{/if}