NearbyList
Places by real distance from a position, in km or miles, with walking time, sorted live as the position moves, and open now from their hours.
Demo
At Rossio. Picked: nothing yet. Units: km.
Get it
It needs theme.qwe, as every component does, and opening_hours.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/opening_hours.qwe$ curl -O https://quillolang.org/components/ui/nearby_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/nearby_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/nearby_list.qwe
let nearby_list_demo_week = [1, 2, 3, 4, 5]// Rossio to Belem along the river, near enough for a walk and far enough to need a tram.let nearby_list_demo_places = [ { id: "a", name: "Pasteis de Belem", kind: "Bakery", lat: 38.6975, lng: -9.2032, hours: nearby_list_demo_week | map((d) => { day: d, open: "08:00", close: "20:00" }) }, { id: "b", name: "Time Out Market", kind: "Food hall", lat: 38.7070, lng: -9.1458, hours: nearby_list_demo_week | map((d) => { day: d, open: "10:00", close: "14:20" }) }, { id: "c", name: "A Brasileira", kind: "Cafe", lat: 38.7106, lng: -9.1415, hours: nearby_list_demo_week | map((d) => { day: d, open: "18:00", close: "02:00" }) }, { id: "d", name: "Museu do Oriente", kind: "Museum", lat: 38.7036, lng: -9.1741 }, { id: "e", name: "Pharmacy on the corner", kind: "Pharmacy", lat: 38.7139, lng: -9.1383, hours: nearby_list_demo_week | map((d) => { day: d, open: "00:00", close: "00:00" }) }, { id: "f", name: "The kiosk nobody mapped", kind: "Kiosk" },]let nearby_list_demo_stops = [{ name: "Rossio", at: [38.7139, -9.1394] }, { name: "Cais do Sodre", at: [38.7061, -9.1444] }, { name: "Belem", at: [38.6967, -9.2060] }]component NearbyListDemoLive() { state stop = 0 state picked = "nothing yet" state unit = "km" state time = "2026-11-03T14:05:00+00:00" state lost = false <div.demo-stack> <NearbyList label="Near you in Lisbon" places={nearby_list_demo_places} position={lost ? [999, "x"] : nearby_list_demo_stops[stop].at} now={time} tick={1} on_pick={(p) => { picked = p.name }} on_units={(u) => { unit = u }}/> <NearbyList class="nearby-list-demo-own" label="By the phone's own clock" position={[38.7139, -9.1394]} places={[{ id: "x", name: "Open every hour", lat: 38.7106, lng: -9.1415, hours: [1, 2, 3, 4, 5, 6, 7] | map((d) => { day: d, open: "00:00", close: "00:00" }) }]}/> <div.demo-row> <button.nearby-list-demo-next type="button" style="min-height: 44px" @click={() => { stop = (stop + 1) % 3 }}>Walk to the next stop</button> <button.nearby-list-demo-late type="button" style="min-height: 44px" @click={() => { time = "2026-11-03T21:30:00+00:00" }}>Make it evening</button> <button.nearby-list-demo-lost type="button" style="min-height: 44px" @click={() => { lost = !lost }}>Lose the position</button> </div> <p.demo-note>At <span class="nearby-list-demo-at">{nearby_list_demo_stops[stop].name}</span>. Picked: <span class="nearby-list-demo-picked">{picked}</span>. Units: <span class="nearby-list-demo-unit">{unit}</span>.</p> </div>}view NearbyListDemo() = <NearbyListDemoLive/>Source
ui/nearby_list.qwe
// NearbyList: places by how far they really are from a position, in km or miles, with the// time to walk there and whether each is open now. The distance is along the Earth's// surface, the order follows the position as it changes, and a row keeps its element when it// moves, so focus stays on it. The position can be given, or come from the phone: "Near me"// follows it, through the app's location services or the web's, and stops when pressed again.// Refused, it says so, keeps the last order, and in the app offers Settings.//// import ui/nearby_list.qwe use NearbyList//// <NearbyList position={[38.7139, -9.1394]} now="2026-11-03T14:05:00+00:00" places={[// { id: 1, name: "Pasteis de Belem", lat: 38.6975, lng: -9.2032, kind: "Bakery",// hours: [{ day: 2, open: "08:00", close: "20:00" }] },// ]} on_pick={(p) => { chosen = p.id }}/>//// places: a list of { id, name, lat, lng (or lon), kind?, hours? }. hours: as OpeningHours// takes them, { day (1 Monday to 7 Sunday), open, close ("HH:MM") }, and a span may close after// midnight; left out, nothing is said about opening. A place with no usable lat and lng comes// last, marked as such. position: [lat, lng] or { lat, lng } (null). units: "km" (default)// or "mi"; the person can change it. now: the local time as an ISO date-time with its offset// ("2026-11-03T14:05:00+01:00"), so the server's page has the same words as the browser's; left// out, the opening times appear once the page has started, from the phone's clock. The// offset is the time hours are read in, as they are the places' local hours. walk_kmh:// walking speed (4.8). detour: streets are longer than a straight line by this much (1.3).// limit: show at most this many (0, all). start: follows from the first moment (false): a// browser may ask the person with nothing yet tapped, so it is better on a tap. gap: seconds// between asks in the app, whose call gives one place at a time (5). tick: seconds between// looks at the clock (30). label: the list's heading ("Nearby"). on_pick(place): a row pressed,// inside one of your components. on_units(units): the person chose km or mi.//// nearby_list_metres, nearby_list_distance, nearby_list_walk, nearby_list_rows and// nearby_list_status are the working.import ui/theme.qwe use ui_clsimport ui/opening_hours.qwe use opening_hours_spans, opening_hours_statuslet nearby_list_css = css`.ui-nearby { container-type: inline-size; font: 15px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-nearby-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }.ui-nearby-title { flex: 1 1 100%; margin: 0; font-size: 18px; font-weight: 700; }.ui-nearby-chip { box-sizing: border-box; min-width: 44px; min-height: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-nearby-chip[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-nearby-chip:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-nearby-units { display: inline-flex; gap: 4px; margin-left: auto; }.ui-nearby-say { margin: 0 0 10px; color: var(--ui-muted); font-size: 14px; }.ui-nearby-say:empty { display: none; }.ui-nearby-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-nearby-row { box-sizing: border-box; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; width: 100%; min-height: 72px; padding: 10px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-nearby-row:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-nearby-row:active { background: var(--ui-subtle); }.ui-nearby-name { font-weight: 600; overflow-wrap: anywhere; }.ui-nearby-kind { color: var(--ui-muted); font-size: 13px; }.ui-nearby-far { grid-column: 2; grid-row: 1; font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; }.ui-nearby-walk { grid-column: 2; grid-row: 2; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; }.ui-nearby-open { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; --t: var(--ui-success); }.ui-nearby-open::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--t); }.ui-nearby-open span { color: var(--ui-muted); font-weight: 400; }.ui-nearby-open[data-state="soon"] { --t: var(--ui-warning); }.ui-nearby-open[data-state="closed"] { --t: var(--ui-danger); }.ui-nearby-empty { padding: 20px 8px; color: var(--ui-muted); text-align: center; }.ui-nearby-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }.ui-nearby { position: relative; }/* With the room, two columns of rows, and the heading beside its buttons. */@container (min-width: 620px) { .ui-nearby-title { flex: 1 1 auto; } .ui-nearby-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .ui-nearby-units { margin-left: 0; }}@media (hover: hover) { .ui-nearby-row:hover { border-color: var(--ui-strong); } }`let nearby_list_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]fn nearby_list_num(x) = (type_of(x) == "int" || type_of(x) == "float" || type_of(x) == "dec") && math.isfinite(float(x))// The phone's clock, in a name the prop `now` does not hide.fn nearby_list_clock() = now()// { lat, lng } from a pair or a map, or null if it is not a place on Earth.fn nearby_list_point(p) { if p == null { return null } let lat = type_of(p) == "list" ? (len(p) > 1 ? p[0] : null) : p.lat let lng = type_of(p) == "list" ? (len(p) > 1 ? p[1] : null) : (p.lng ?? p.lon) if !nearby_list_num(lat) || !nearby_list_num(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180 { return null } { lat: float(lat), lng: float(lng) }}// Metres along the ground: the haversine, on a sphere of the Earth's mean radius.fn nearby_list_metres(a, b) { let la = math.radians(a.lat) let lb = math.radians(b.lat) let dlat = lb - la let dlng = math.radians(b.lng - a.lng) let h = pow(math.sin(dlat / 2), 2) + math.cos(la) * math.cos(lb) * pow(math.sin(dlng / 2), 2) 2 * 6371008.8 * math.atan2(sqrt(h), sqrt(max([1 - h, 0])))}// 120 m, 1.2 km, 12 km; 350 ft, 0.4 mi, 12 mi. No finer than a phone knows, and a text that// does not change at each step of a walk.fn nearby_list_distance(m, units: str = "km") { if !nearby_list_num(m) { return "" } if units == "mi" { let miles = m / 1609.344 if miles < 0.1 { return `${format_number(round(m / 0.3048 / 10) * 10, "en-US", 0)} ft` } return miles < 10 ? `${format_number(miles, "en-US", 1)} mi` : `${format_number(round(miles), "en-US", 0)} mi` } if m < 1000 { let tens = round(m / 10) * 10 return tens >= 1000 ? "1.0 km" : `${format_number(tens, "en-US", 0)} m` } m < 10000 ? `${format_number(m / 1000, "en-US", 1)} km` : `${format_number(round(m / 1000), "en-US", 0)} km`}// Minutes on foot, whole and rounded up, as no one is sorry to arrive early.fn nearby_list_walk_minutes(m, kmh, detour) { let speed = nearby_list_num(kmh) && kmh > 0 ? float(kmh) : 4.8 let longer = nearby_list_num(detour) && detour >= 1 ? float(detour) : 1 max([1, ceil(m * longer / (speed * 1000 / 60))])}// "6 min walk", "1 h 5 min walk"; past four hours nobody means to walk it.fn nearby_list_walk(m, kmh = 4.8, detour = 1.3) { if !nearby_list_num(m) { return "" } let mins = nearby_list_walk_minutes(m, kmh, detour) if mins >= 240 { return "A long walk" } if mins < 60 { return `${mins} min walk` } let rest = mins % 60 rest == 0 ? `${floor(mins / 60)} h walk` : `${floor(mins / 60)} h ${rest} min walk`}// Only the hours that can be read; a mistake in one row does not lose the rest.fn nearby_list_hours(hours) { if type_of(hours) != "list" { return [] } hours | filter((h) => type_of(h) == "map" && type_of(h.day) == "int" && h.day >= 1 && h.day <= 7 && type_of(h.open) == "str" && type_of(h.close) == "str" && re.test(h.open, "^[0-2][0-9]:[0-5][0-9]$") && re.test(h.close, "^[0-2][0-9]:[0-5][0-9]$"))}// The minute of the week, Monday 00:00 being 0, of a date-time, as written.fn nearby_list_week_minute(t: str) { let p = date.parts(t) (p.weekday - 1) * 1440 + p.hour * 60 + p.minute}// Open now? { state: "open", "soon" or "closed", head, rest }, or null when nothing is known.fn nearby_list_status(hours, t, soon: int = 30) { let good = nearby_list_hours(hours) if t == null || len(good) == 0 { return null } let s = opening_hours_status(opening_hours_spans(good), nearby_list_week_minute(t), soon) if s.open { return s.soon ? { state: "soon", head: "Closes soon", rest: s.until } : { state: "open", head: "Open", rest: `closes ${s.until}` } } if s.next == null { return { state: "closed", head: "Closed", rest: "" } } { state: "closed", head: "Closed", rest: `opens ${nearby_list_days[s.next.day - 1]} ${s.next.open}` }}// The rows, nearest first. Fixes wander, so distances are compared to the nearest ten metres// and the list's own order breaks a tie: two shops side by side do not change places at// every fix. Places with no position come last, in the order given.fn nearby_list_rows(places, here, t, kmh, detour, limit: int = 0, open_only: bool = false) { let rows = (type_of(places) == "list" ? places : []) | filter((p) => type_of(p) == "map") | enumerate() | map((pair) => { let p = pair[1] let at = nearby_list_point(p) let m = at != null && here != null ? nearby_list_metres(here, at) : null { place: p, id: p.id ?? p.name ?? pair[0], order: pair[0], metres: m, status: nearby_list_status(p.hours, t), located: at != null } }) let kept = open_only ? rows | filter((r) => r.status != null && r.status.state != "closed") : rows // One number, as sort_by takes no list: tens of metres, then the place in the list. let sorted = kept | sort_by((r) => (r.metres == null ? 100000000 : round(r.metres / 10)) * 100000 + r.order) limit > 0 ? take(sorted, limit) : sorted}// A fix moved far enough to be worth another look: what is left is GPS wandering.fn nearby_list_moved(a, b, by: num = 5) = a == null || b == null || nearby_list_metres(a, b) >= by// What a state says. Only the app has a page in Settings to send a person to.fn nearby_list_message(status, in_app, located) = match status { "asking" => "Finding where you are." "refused" => in_app ? "Location is turned off for this app. You can allow it in Settings." : "Location is blocked for this page. Allow it in your browser's site settings." "off" => "No location came. Check that location services are on." "none" => "This browser cannot give a location." "insecure" => "Browsers give a location only to pages on https." _ => located ? "" : "Press Near me to sort these by distance."}fn nearby_list_app_state(text) { let t = lower(str(text)) contains(t, "for this app") || contains(t, "denied") || contains(t, "not allowed") ? "refused" : "off"}view NearbyListRow(row, units: str, walk_kmh, detour, on_press) { let s = row.status <button.ui-nearby-row type="button" data-id={str(row.id)} @click={on_press}> <span.ui-nearby-name>{row.place.name ?? "A place"}</span> {row.metres != null ? <span.ui-nearby-far>{nearby_list_distance(row.metres, units)}</span> : <span.ui-nearby-far>{row.located ? "" : "No position"}</span>} <span.ui-nearby-kind>{row.place.kind ?? ""}</span> <span.ui-nearby-walk>{row.metres != null ? nearby_list_walk(row.metres, walk_kmh, detour) : ""}</span> {s != null && <span.ui-nearby-open data-state={s.state}>{s.head}<span>{s.rest}</span></span>} </button>}component NearbyList(places: list = [], position = null, units: str = "km", now: str? = null, walk_kmh: num = 4.8, detour: num = 1.3, limit: int = 0, start: bool = false, gap: num = 5, tick: num = 30, label: str = "Nearby", on_pick = null, on_units = null, class: str? = null) { style global nearby_list_css state unit = units == "mi" ? "mi" : "km" state open_only = false state fix = null // where the phone says it is, which wins over `position` state status = "idle" state in_app = false state watch_id = null state polling = false state busy = false // an ask of the app's still out: one at a time state to_settings = false state base = type_of(now) == "str" && date.valid(now, "date-time") ? date.ms(now) : null state offset = type_of(now) == "str" && date.valid(now, "date-time") ? date.parts(now).offset : null state seen_now = now state since = 0 // the phone's clock when `base` was read state moved_on = 0 // ms since then let settle = () => { since = nearby_list_clock() moved_on = 0 } // A new `now` from the page puts the clock right. if now != seen_now { seen_now = now if type_of(now) == "str" && date.valid(now, "date-time") { base = date.ms(now) offset = date.parts(now).offset settle() } } if tick > 0 && base != null { every(round(tick * 1000), () => { moved_on = nearby_list_clock() - since }) } let stop = () => { if watch_id != null { js("navigator.geolocation.clearWatch", watch_id) watch_id = null } polling = false } let got = (lat, lng) => { let p = nearby_list_point([lat, lng]) if p == null { status = "off" } else { status = "live" if nearby_list_moved(fix, p) { fix = p } } } let ask_app = () => { if !busy && js("document.visibilityState") == "visible" { busy = true let r = js("quilloNative.location") busy = false if is_err(r) { status = nearby_list_app_state(r.error) stop() } else { got(r.lat, r.lon) } } } let follow = () => { let win = js("window") status = "asking" if win.quilloNative != null { in_app = true polling = true ask_app() } else if win.navigator.geolocation == null { status = "none" } else if win.isSecureContext != true { status = "insecure" } else { watch_id = js("navigator.geolocation.watchPosition", (pos) => { got(pos.coords.latitude, pos.coords.longitude) }, (e) => { // A place that merely is not there yet is not the end of following. if e.code == 1 { status = "refused" stop() } else if status == "asking" { status = "off" } }, { enableHighAccuracy: true, maximumAge: 2000, timeout: 20000 }) } } if polling { every(round(max([gap, 1]) * 1000), ask_app) } let following = watch_id != null || polling let given = nearby_list_point(position) let here = fix ?? given let local = base != null ? date.from_ms(base + moved_on, offset) : null let rows = nearby_list_rows(places, here, local, walk_kmh, detour, limit, open_only) let say = nearby_list_message(status, in_app, here != null) let nearest = len(rows) > 0 && rows[0].metres != null ? `Nearest now: ${rows[0].place.name ?? "a place"}, ${nearby_list_distance(rows[0].metres, unit)}` : "" <section class={ui_cls(["ui-nearby", class])} aria-label={label} @mount={(_el) => { let d = js("document") settle() if base == null { // No `now` from the page: the phone's own time, in its own zone. let off = 0 - js_new("Date").getTimezoneOffset() let hh = floor(abs(off) / 60) offset = `${off < 0 ? "-" : "+"}${hh < 10 ? "0" : ""}${hh}:${abs(off) % 60 < 10 ? "0" : ""}${abs(off) % 60}` base = nearby_list_clock() } let back = (_e = null) => { if to_settings && js("document.visibilityState") == "visible" { to_settings = false follow() } } d.addEventListener("visibilitychange", back) if start { follow() } () => { d.removeEventListener("visibilitychange", back) stop() } }}> <div.ui-nearby-bar> <h3.ui-nearby-title>{label}</h3> <button.ui-nearby-chip.ui-nearby-me type="button" aria-pressed={following ? "true" : "false"} @click={() => { if following { stop(); status = "idle" } else { follow() } }}>Near me</button> <button.ui-nearby-chip.ui-nearby-only type="button" aria-pressed={open_only ? "true" : "false"} @click={() => { open_only = !open_only }}>Open now</button> <span.ui-nearby-units role="group" aria-label="Units"> {for u in ["km", "mi"] => <button.ui-nearby-chip key={u} type="button" data-unit={u} aria-pressed={unit == u ? "true" : "false"} @click={() => { unit = u on_units && on_units(u) }}>{u}</button>} </span> </div> <p.ui-nearby-say role="status">{say}</p> {status == "refused" && in_app && <button.ui-nearby-chip.ui-nearby-settings type="button" @click={() => { to_settings = true js("quilloNative.openSettings") }}>Open Settings</button>} <span.ui-nearby-sr role="status">{nearest}</span> {len(rows) == 0 ? <p.ui-nearby-empty>{open_only ? "Nothing is open just now." : "No places to show."}</p> : <ul.ui-nearby-list> {for r in rows => <li key={r.id}> <NearbyListRow row={r} units={unit} walk_kmh={walk_kmh} detour={detour} on_press={() => { on_pick && on_pick(r.place) }}/> </li>} </ul>} </section>}Related
PinDropperA fixed centre pin with the map moving under it: gives the coordinates and calls on_pick, with arrow keys and buttons as equals of the drag.3 views · 6 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsContactListPeople grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.3 views · 8 downloadsDragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsExpandingRowA row that opens in place with content pushed down smoothly, keeps only one open if asked, and scrolls itself into view.4 views · 7 downloadsGeofenceStatusInside or outside of areas given as data, by real distance, with a band at each edge so that it does not flap, and poor fixes held.3 views · 5 downloads
Thread
Thread 0 posts
On NearbyList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.