Search states and countries for the profile location
This commit is contained in:
parent
eef6c19129
commit
c84077b86e
6 changed files with 95 additions and 152 deletions
|
|
@ -1560,14 +1560,14 @@ Acceptance:
|
|||
|
||||
### US-131 — Say where you are and find members on a map
|
||||
|
||||
As a member, I want to give a town, state or country on my profile and see where
|
||||
As a member, I want to give a city, state or country on my profile and see where
|
||||
the rest of a space is, so that I can find the people near me.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The location field on the profile form searches places by name, leaves out
|
||||
towns of under a thousand people, and publishes what was picked to kind 0.
|
||||
- Choosing a town publishes a geohash with it; choosing a state or a country
|
||||
- The location field on the profile form searches places by name, offers cities,
|
||||
states and countries, and publishes what was picked to kind 0.
|
||||
- Choosing a city publishes a geohash with it; choosing a state or a country
|
||||
publishes the name alone, so nothing finer than the answer given goes out.
|
||||
- The directory offers a map whenever a member has a geohash, pins each of them
|
||||
with their avatar, and opens a pin's profile when it is clicked.
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
|
|||
// Transcription and speech both go here, against whichever key the user has saved.
|
||||
const OPENROUTER_ORIGIN = "https://openrouter.ai"
|
||||
|
||||
// The place search behind the profile's location field, in src/app/location.ts.
|
||||
const GEOCODING_ORIGIN = "https://geocoding-api.open-meteo.com"
|
||||
|
||||
// .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
|
||||
const CHECKOUT_ORIGIN = "https://checkout.test"
|
||||
|
||||
|
|
@ -618,20 +615,28 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
|
|||
},
|
||||
)
|
||||
|
||||
// One row of Open-Meteo's answer. `feature_code` is PCLI for a country and PPL* for a town.
|
||||
// One row of Photon's answer. Anything but a city, state or country is left out of the results.
|
||||
export type GeocodedPlace = {
|
||||
type: string
|
||||
name: string
|
||||
admin1?: string
|
||||
state?: string
|
||||
country?: string
|
||||
feature_code: string
|
||||
population?: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/** The place search, answering every term with the same places. */
|
||||
export const mockGeocoding = (context: BrowserContext, results: GeocodedPlace[]) =>
|
||||
context.route(`${GEOCODING_ORIGIN}/**`, route => route.fulfill({json: {results}}))
|
||||
/** The place search behind the profile's location field, answering every term with the same places. */
|
||||
export const mockGeocoding = (context: BrowserContext, places: GeocodedPlace[]) =>
|
||||
context.route("https://photon.komoot.io/**", route =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
features: places.map(({type, name, state, country, latitude, longitude}) => ({
|
||||
geometry: {coordinates: [longitude, latitude]},
|
||||
properties: {type, name, state, country},
|
||||
})),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
/** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */
|
||||
export const mockImages = (context: BrowserContext) =>
|
||||
|
|
|
|||
|
|
@ -652,22 +652,27 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
|
|||
|
||||
await mockGeocoding(page.context(), [
|
||||
{
|
||||
type: "city",
|
||||
name: "Portland",
|
||||
admin1: "Oregon",
|
||||
state: "Oregon",
|
||||
country: "United States",
|
||||
feature_code: "PPLA2",
|
||||
population: 652503,
|
||||
latitude: 45.52345,
|
||||
longitude: -122.67621,
|
||||
},
|
||||
{
|
||||
name: "Portlaw",
|
||||
admin1: "Munster",
|
||||
country: "Ireland",
|
||||
feature_code: "PPL",
|
||||
population: 900,
|
||||
latitude: 52.28833,
|
||||
longitude: -7.32056,
|
||||
type: "state",
|
||||
name: "Oregon",
|
||||
country: "United States",
|
||||
latitude: 43.93179,
|
||||
longitude: -120.76562,
|
||||
},
|
||||
{
|
||||
type: "street",
|
||||
name: "Portland Avenue",
|
||||
state: "Oregon",
|
||||
country: "United States",
|
||||
latitude: 45.51,
|
||||
longitude: -122.65,
|
||||
},
|
||||
])
|
||||
|
||||
|
|
@ -679,10 +684,13 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
|
|||
|
||||
const form = await openEditForm()
|
||||
|
||||
await form.getByPlaceholder("Search for a town").fill("portl")
|
||||
await form.getByPlaceholder("Search for a city").fill("portl")
|
||||
|
||||
// A town of under a thousand people is not somewhere to be from.
|
||||
await expect(form.getByRole("button", {name: "Portlaw, Munster, Ireland"})).toHaveCount(0)
|
||||
// A state is a place to be from; a street is not.
|
||||
await expect(form.getByRole("button", {name: "Oregon, United States"})).toBeVisible()
|
||||
await expect(
|
||||
form.getByRole("button", {name: "Portland Avenue, Oregon, United States"}),
|
||||
).toHaveCount(0)
|
||||
|
||||
await form.getByRole("button", {name: "Portland, Oregon, United States"}).click()
|
||||
await form.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
|
@ -725,7 +733,7 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
|
|||
|
||||
await expect(pins).toHaveCount(0)
|
||||
|
||||
// A country is not a pin, so choosing one takes alice off the map.
|
||||
// A state is not a pin, so choosing one takes alice off the map.
|
||||
await page.goto(profilePath(users.alice.pubkey))
|
||||
|
||||
const secondForm = await openEditForm()
|
||||
|
|
@ -733,18 +741,14 @@ test("US-131 say where you are and find members on a map", async ({seed, as}) =>
|
|||
await expect(secondForm.getByText("Portland, Oregon, United States")).toBeVisible()
|
||||
|
||||
await secondForm.getByRole("button", {name: "Clear location"}).click()
|
||||
await secondForm.getByPlaceholder("Search for a town").fill("portl")
|
||||
await secondForm.getByRole("button", {name: "Portland, Oregon, United States"}).click()
|
||||
await secondForm.getByLabel("How much of your location to show").selectOption({label: "Country"})
|
||||
|
||||
await expect(secondForm.getByText("United States", {exact: true})).toBeVisible()
|
||||
|
||||
await secondForm.getByPlaceholder("Search for a city").fill("oreg")
|
||||
await secondForm.getByRole("button", {name: "Oregon, United States"}).click()
|
||||
await secondForm.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
|
||||
|
||||
const republished = getPublishedEvents(page.context(), PROFILE)
|
||||
|
||||
expect(JSON.parse(republished[1].content).location).toBe("United States")
|
||||
expect(JSON.parse(republished[1].content).location).toBe("Oregon, United States")
|
||||
expect(republished[1].tags.map(tag => tag[0])).not.toContain("g")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
<script lang="ts">
|
||||
import {debounce} from "throttle-debounce"
|
||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import {
|
||||
displayPlace,
|
||||
granularityLabels,
|
||||
placeGeohash,
|
||||
placeGranularities,
|
||||
searchPlaces,
|
||||
} from "@app/location"
|
||||
import type {Granularity, Place} from "@app/location"
|
||||
import {displayPlace, placeGeohash, searchPlaces} from "@app/location"
|
||||
import type {Place} from "@app/location"
|
||||
|
||||
type Props = {
|
||||
location?: string
|
||||
|
|
@ -22,13 +15,6 @@
|
|||
|
||||
let {location = $bindable(), geohash = $bindable()}: Props = $props()
|
||||
|
||||
const fullName = (candidate: Place) => displayPlace(candidate, placeGranularities(candidate)[0])
|
||||
|
||||
const apply = (picked: Place) => {
|
||||
location = displayPlace(picked, granularity)
|
||||
geohash = placeGeohash(picked, granularity)
|
||||
}
|
||||
|
||||
const search = debounce(400, async (value: string) => {
|
||||
try {
|
||||
const places = await searchPlaces(value)
|
||||
|
|
@ -59,21 +45,13 @@
|
|||
}
|
||||
|
||||
const pick = (picked: Place) => {
|
||||
place = picked
|
||||
granularity = placeGranularities(picked)[0]
|
||||
location = displayPlace(picked)
|
||||
geohash = placeGeohash(picked)
|
||||
term = ""
|
||||
results = []
|
||||
apply(picked)
|
||||
}
|
||||
|
||||
const changeGranularity = () => {
|
||||
if (place) {
|
||||
apply(place)
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
place = undefined
|
||||
location = undefined
|
||||
geohash = undefined
|
||||
}
|
||||
|
|
@ -82,8 +60,6 @@
|
|||
let results: Place[] = $state([])
|
||||
let loading = $state(false)
|
||||
let failed = $state(false)
|
||||
let place: Place | undefined = $state()
|
||||
let granularity: Granularity = $state("city")
|
||||
let list: HTMLElement | undefined = $state()
|
||||
|
||||
// The field sits deep enough in the form that results can open below the fold.
|
||||
|
|
@ -95,50 +71,37 @@
|
|||
</script>
|
||||
|
||||
{#if location}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="input flex w-full items-center gap-2">
|
||||
<Icon icon={MapPoint} />
|
||||
<span class="min-w-0 grow truncate">{location}</span>
|
||||
<Button
|
||||
class="button button-ghost button-xs button-square"
|
||||
aria-label="Clear location"
|
||||
onclick={clear}>
|
||||
<Icon size={4} icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
{#if place}
|
||||
<select
|
||||
class="select input"
|
||||
aria-label="How much of your location to show"
|
||||
bind:value={granularity}
|
||||
onchange={changeGranularity}>
|
||||
{#each placeGranularities(place) as option (option)}
|
||||
<option value={option}>{granularityLabels[option]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="input flex w-full items-center gap-2">
|
||||
<Icon icon={MapPoint} />
|
||||
<span class="min-w-0 grow truncate">{location}</span>
|
||||
<Button
|
||||
class="button button-ghost button-xs button-square"
|
||||
aria-label="Clear location"
|
||||
onclick={clear}>
|
||||
<Icon size={4} icon={CloseCircle} />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="input flex w-full items-center gap-2">
|
||||
<Icon icon={Magnifier} />
|
||||
<Icon icon={MapPoint} />
|
||||
<input
|
||||
bind:value={term}
|
||||
oninput={onInput}
|
||||
class="min-w-0 grow"
|
||||
type="text"
|
||||
placeholder="Search for a town, city, or country..." />
|
||||
placeholder="Search for a city, state, or country..." />
|
||||
<Spinner {loading} />
|
||||
</label>
|
||||
{#if failed}
|
||||
<p class="text-sm text-error">Location search is unavailable right now.</p>
|
||||
{:else if results.length > 0}
|
||||
<ul bind:this={list} class="menu bg-surface max-h-60 gap-1 overflow-auto rounded-2xl p-2">
|
||||
{#each results as result (result.latitude + "," + result.longitude)}
|
||||
{#each results as result (displayPlace(result))}
|
||||
<li>
|
||||
<Button class="w-full text-left" onclick={() => pick(result)}>
|
||||
<Icon size={4} icon={MapPoint} />
|
||||
{fullName(result)}
|
||||
{displayPlace(result)}
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@
|
|||
<InputLocation bind:location={values.profile.location} bind:geohash={values.geohash} />
|
||||
{/snippet}
|
||||
{#snippet info()}
|
||||
A town, state or country. Only a town puts you on a space's member map.
|
||||
A city, state or country. Only a city puts you on a space's member map.
|
||||
{/snippet}
|
||||
</Field>
|
||||
{#if !isSignup}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,57 @@
|
|||
import {removeUndefined} from "@welshman/lib"
|
||||
import {removeUndefined, uniqBy} from "@welshman/lib"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import type {ProfileReader} from "@welshman/domain"
|
||||
import {encodeGeohash} from "@lib/geohash"
|
||||
|
||||
export type Place = {
|
||||
city?: string
|
||||
region?: string
|
||||
country: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
export type Granularity = "city" | "region" | "country"
|
||||
|
||||
export const granularityLabels: Record<Granularity, string> = {
|
||||
city: "City",
|
||||
region: "State or region",
|
||||
country: "Country",
|
||||
}
|
||||
|
||||
type GeocodingResult = {
|
||||
type: "city" | "state" | "country"
|
||||
name: string
|
||||
admin1?: string
|
||||
state?: string
|
||||
country?: string
|
||||
feature_code: string
|
||||
population?: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
// Open-Meteo indexes GeoNames' populated places and countries, never a state on its own.
|
||||
export const searchPlaces = async (term: string) => {
|
||||
const url = new URL("https://geocoding-api.open-meteo.com/v1/search")
|
||||
|
||||
url.searchParams.set("name", term)
|
||||
url.searchParams.set("count", "20")
|
||||
url.searchParams.set("language", "en")
|
||||
url.searchParams.set("format", "json")
|
||||
|
||||
const response = await fetch(url)
|
||||
const {results = []}: {results?: GeocodingResult[]} = await response.json()
|
||||
const places: Place[] = []
|
||||
|
||||
for (const result of results) {
|
||||
const {name, admin1, country, feature_code, population, latitude, longitude} = result
|
||||
|
||||
if (feature_code === "PCLI") {
|
||||
places.push({country: name, latitude, longitude})
|
||||
} else if (country && population && population >= 1000) {
|
||||
places.push({city: name, region: admin1, country, latitude, longitude})
|
||||
}
|
||||
}
|
||||
|
||||
return places
|
||||
type GeocodingFeature = {
|
||||
geometry: {coordinates: [number, number]}
|
||||
properties: {type: string; name: string; state?: string; country?: string}
|
||||
}
|
||||
|
||||
export const placeGranularities = (place: Place) => {
|
||||
const granularities: Granularity[] = []
|
||||
const isPlaceType = (type: string): type is Place["type"] =>
|
||||
type === "city" || type === "state" || type === "country"
|
||||
|
||||
if (place.city) {
|
||||
granularities.push("city")
|
||||
}
|
||||
const toPlace = ({geometry, properties}: GeocodingFeature): Place | undefined => {
|
||||
const {type, name, state, country} = properties
|
||||
const [longitude, latitude] = geometry.coordinates
|
||||
|
||||
if (place.region) {
|
||||
granularities.push("region")
|
||||
}
|
||||
|
||||
granularities.push("country")
|
||||
|
||||
return granularities
|
||||
return isPlaceType(type) ? {type, name, state, country, latitude, longitude} : undefined
|
||||
}
|
||||
|
||||
export const displayPlace = (place: Place, granularity: Granularity) =>
|
||||
export const displayPlace = (place: Place) =>
|
||||
removeUndefined([
|
||||
granularity === "city" ? place.city : undefined,
|
||||
granularity === "country" ? undefined : place.region,
|
||||
place.country,
|
||||
place.name,
|
||||
place.state,
|
||||
place.type === "country" ? undefined : place.country,
|
||||
]).join(", ")
|
||||
|
||||
// Photon indexes OpenStreetMap for autocomplete; osm_tag=place keeps streets and buildings out.
|
||||
export const searchPlaces = async (term: string) => {
|
||||
const url = new URL("https://photon.komoot.io/api/")
|
||||
|
||||
url.searchParams.set("q", term)
|
||||
url.searchParams.set("limit", "15")
|
||||
url.searchParams.set("lang", "en")
|
||||
url.searchParams.set("osm_tag", "place")
|
||||
|
||||
const response = await fetch(url)
|
||||
const {features = []}: {features?: GeocodingFeature[]} = await response.json()
|
||||
|
||||
return uniqBy(displayPlace, removeUndefined(features.map(toPlace)))
|
||||
}
|
||||
|
||||
// A pin is finer than a state or a country asked to be, so only a city publishes one.
|
||||
export const placeGeohash = (place: Place, granularity: Granularity) =>
|
||||
granularity === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
|
||||
export const placeGeohash = (place: Place) =>
|
||||
place.type === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
|
||||
|
||||
export const getProfileLocation = (profile?: ProfileReader): string | undefined =>
|
||||
profile?.values.location
|
||||
|
|
|
|||
Loading…
Reference in a new issue