feat: location selector in route UI
Some checks failed
TrafficCue CI / check (push) Failing after 47s
TrafficCue CI / build (push) Failing after 41s

This commit is contained in:
Cfp
2025-08-21 15:45:16 +02:00
parent 15e88979d5
commit 03b129f947
5 changed files with 175 additions and 74 deletions

View File

@ -130,5 +130,11 @@
"choose-lang": "Wählen Sie Ihre Sprache", "choose-lang": "Wählen Sie Ihre Sprache",
"first-vehicle": "Erstellen wir Ihr erstes Fahrzeug." "first-vehicle": "Erstellen wir Ihr erstes Fahrzeug."
} }
} },
"location-selector": {
"current": "Mein Standort",
"searching": "Suchen...",
"no-results": "Keine Orte gefunden."
},
"unsave": "Löschen"
} }

View File

@ -132,5 +132,10 @@
"first-vehicle": "Let's create your first vehicle." "first-vehicle": "Let's create your first vehicle."
} }
}, },
"unsave": "Unsave" "unsave": "Unsave",
"location-selector": {
"current": "Current Location",
"searching": "Searching...",
"no-results": "No locations found."
}
} }

View File

@ -6,35 +6,56 @@
import * as Popover from "$lib/components/ui/popover/index.js"; import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { m } from "$lang/messages";
import { BriefcaseIcon, HomeIcon, LocateIcon, MapPinIcon, SchoolIcon } from "@lucide/svelte";
import { geocode } from "$lib/saved.svelte";
import { reverseGeocode, search, type Feature } from "$lib/services/Search";
const frameworks = [ const locations = [
{ {
value: "sveltekit", value: "current",
label: "SvelteKit", label: m["location-selector.current"](),
icon: LocateIcon
}, },
{ {
value: "next.js", value: "home",
label: "Next.js", label: m["saved.home"](),
subtext: geocode("home"),
icon: HomeIcon
}, },
{ {
value: "nuxt.js", value: "school",
label: "Nuxt.js", label: m["saved.school"](),
subtext: geocode("school"),
icon: SchoolIcon
}, },
{ {
value: "remix", value: "work",
label: "Remix", label: m["saved.work"](),
}, subtext: geocode("work"),
{ icon: BriefcaseIcon
value: "astro", }
label: "Astro",
},
]; ];
let open = $state(false); let open = $state(false);
let value = $state(""); let { value = $bindable() } = $props();
let triggerRef = $state<HTMLButtonElement>(null!); let triggerRef = $state<HTMLButtonElement>(null!);
let searchbarText = $state("");
let searchText = $derived.by(debounce(() => searchbarText, 300));
let searching = $state(false);
let searchResults: Feature[] = $state([]);
const selectedValue = $derived(value === "location" ? "My Location" : value); async function getCoordLabel(value: `${number},${number}`) {
const splitter = value.split(",");
const res = await reverseGeocode({ lat: parseFloat(splitter[0]), lon: parseFloat(splitter[1]) })
if(res.length == 0) return "<unknown>";
const feature = res[0];
return feature.properties.name;
}
const selectedValue = $derived(
new Promise(async r => { r(locations.find((f) => f.value === value)?.label || await getCoordLabel(value)) })
);
// We want to refocus the trigger button when the user selects // We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the // an item from the list so users can continue navigating the
@ -45,61 +66,123 @@
triggerRef.focus(); triggerRef.focus();
}); });
} }
function debounce<T>(getter: () => T, delay: number): () => T | undefined {
let value = $state<T>();
let timer: NodeJS.Timeout;
$effect(() => {
const newValue = getter(); // read here to subscribe to it
clearTimeout(timer);
timer = setTimeout(() => (value = newValue), delay);
return () => clearTimeout(timer);
});
return () => value;
}
$effect(() => {
if (!searchText) {
searchResults = [];
return;
}
if (searchText.length > 0) {
searching = true;
search(searchText, 0, 0).then((results) => {
searchResults = results;
searching = false;
});
} else {
searchResults = [];
}
});
</script> </script>
<Popover.Root bind:open> <Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}> <Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props }: { props: Record<string, unknown> })} {#snippet child({ props })}
<Button <Button
variant="outline" variant="outline"
class="justify-between"
{...props} {...props}
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
style="width: 100%; flex-shrink: unset; justify-content: space-between;"
> >
{selectedValue || "Select a location..."} {#await selectedValue then selected}
{selected || "Select..."}
{/await}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="w-[200px] p-0"> <Popover.Content class="w-[200px] p-0">
<Command.Root> <Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." /> <Command.Input placeholder="Search..." bind:value={searchbarText} />
<Command.List> <Command.List>
<Command.Empty>No location found.</Command.Empty> <Command.Empty>
{#if searching}
{m["location-selector.searching"]()}
{:else}
{m["location-selector.no-results"]()}
{/if}
</Command.Empty>
<Command.Group> <Command.Group>
<Command.Item {#if searchbarText == ""}
value="location" {#each locations as location}
onSelect={() => { <Command.Item
value = "location"; value={location.value}
closeAndFocusTrigger(); onSelect={() => {
}} value = location.value;
> closeAndFocusTrigger();
<CheckIcon }}
class={cn( style="flex-direction: column; align-items: start;"
"mr-2 size-4", >
value !== "location" && "text-transparent", <div style="display: flex; align-items: center; gap: 5px; width: 100%;">
)} <location.icon
/> class={cn(
My Location "mr-2 size-4"
</Command.Item> )}
</Command.Group> />
<Command.Group> {location.label}
{#each frameworks as framework (framework.value)} <CheckIcon
class={cn(
"mr-2 size-4 ml-auto",
value !== location.value && "text-transparent"
)}
/>
</div>
{#await location.subtext then subtext}
{#if subtext}
<span>{subtext}</span>
{/if}
{/await}
</Command.Item>
{/each}
{/if}
{#each searchResults as result}
{@const resultValue = result.geometry.coordinates[1] + "," + result.geometry.coordinates[0]}
<Command.Item <Command.Item
value={framework.value} value={resultValue}
onSelect={() => { onSelect={() => {
value = framework.value; value = resultValue;
closeAndFocusTrigger(); closeAndFocusTrigger();
}} }}
style="flex-direction: column; align-items: start;"
> >
<CheckIcon <div style="display: flex; align-items: center; gap: 5px; width: 100%;">
class={cn( <MapPinIcon
"mr-2 size-4", class={cn(
value !== framework.value && "text-transparent", "mr-2 size-4"
)} )}
/> />
{framework.label} {result.properties.name}
<CheckIcon
class={cn(
"mr-2 size-4 ml-auto",
value !== resultValue && "text-transparent"
)}
/>
</div>
<span>{result.properties.street}{result.properties.housenumber ? " " + result.properties.housenumber : ""}, {result.properties.city}</span>
</Command.Item> </Command.Item>
{/each} {/each}
</Command.Group> </Command.Group>

View File

@ -20,6 +20,7 @@
import { location } from "../location.svelte"; import { location } from "../location.svelte";
import { saved } from "$lib/saved.svelte"; import { saved } from "$lib/saved.svelte";
import { m } from "$lang/messages"; import { m } from "$lang/messages";
import LocationSelect from "../LocationSelect.svelte";
let { let {
from, from,
@ -29,7 +30,7 @@
to?: string; to?: string;
} = $props(); } = $props();
let fromLocation = $state(from || ""); let fromLocation = $state(from || "current");
let toLocation = $state(to || ""); let toLocation = $state(to || "");
let routes: Trip[] | null = $state(null); let routes: Trip[] | null = $state(null);
@ -57,44 +58,37 @@
<div class="flex flex-col gap-2 w-full mb-2"> <div class="flex flex-col gap-2 w-full mb-2">
<div class="flex gap-2 items-center w-full"> <div class="flex gap-2 items-center w-full">
<CircleDotIcon /> <CircleDotIcon />
<Input bind:value={fromLocation} /> <LocationSelect bind:value={fromLocation} />
</div> </div>
<div class="flex items-center justify-center w-full"> <div class="flex items-center justify-center w-full">
<CircleArrowDown /> <CircleArrowDown />
</div> </div>
<div class="flex gap-2 items-center w-full"> <div class="flex gap-2 items-center w-full">
<CircleDotIcon /> <CircleDotIcon />
<Input bind:value={toLocation} /> <LocationSelect bind:value={toLocation} />
</div> </div>
<span>
<!-- eslint-disable-next-line -->
{@html m["sidebar.route.help"]()}
</span>
</div> </div>
<Button <Button
onclick={async () => { onclick={async () => {
console.log(fromLocation, toLocation);
const FROM: WorldLocation = const FROM: WorldLocation =
fromLocation == "current" fromLocation == "current"
? { lat: location.lat, lon: location.lng } ? { lat: location.lat, lon: location.lng }
: fromLocation == "home" : saved[fromLocation]
? saved.home ? saved[fromLocation]
: fromLocation == "work" : {
? saved.work lat: parseFloat(fromLocation.split(",")[0]),
: { lon: parseFloat(fromLocation.split(",")[1]),
lat: parseFloat(fromLocation.split(",")[0]), };
lon: parseFloat(fromLocation.split(",")[1]),
};
const TO: WorldLocation = const TO: WorldLocation =
toLocation == "current" toLocation == "current"
? { lat: location.lat, lon: location.lng } ? { lat: location.lat, lon: location.lng }
: toLocation == "home" : saved[toLocation]
? saved.home ? saved[toLocation]
: toLocation == "work" : {
? saved.work lat: parseFloat(toLocation.split(",")[0]),
: { lon: parseFloat(toLocation.split(",")[1]),
lat: parseFloat(toLocation.split(",")[0]), };
lon: parseFloat(toLocation.split(",")[1]),
};
const req = createValhallaRequest(selectedVehicle() ?? DefaultVehicle, [ const req = createValhallaRequest(selectedVehicle() ?? DefaultVehicle, [
FROM, FROM,
TO, TO,

View File

@ -1,3 +1,5 @@
import { reverseGeocode } from "./services/Search";
export const saved: Record<string, WorldLocation> = $state( export const saved: Record<string, WorldLocation> = $state(
JSON.parse(localStorage.getItem("saved") ?? "{}"), JSON.parse(localStorage.getItem("saved") ?? "{}"),
); );
@ -5,3 +7,14 @@ export const saved: Record<string, WorldLocation> = $state(
export function saveLocations() { export function saveLocations() {
localStorage.setItem("saved", JSON.stringify(saved)); localStorage.setItem("saved", JSON.stringify(saved));
} }
export async function geocode(name: string) {
const loc = saved[name];
if(!loc) return;
const geocode = await reverseGeocode(loc);
if(geocode.length == 0) {
return;
}
const feature = geocode[0];
return `${feature.properties.street}${feature.properties.housenumber ? (" " + feature.properties.housenumber) : ""}, ${feature.properties.city}`;
}