feat: location selector in route UI
This commit is contained in:
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user