Mobile

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.

#mobile#interactive#tablet#location#map#list

Demo

Preview

Near you in Lisbon

Nearest now: Pharmacy on the corner, 100 m

By the phone's own clock

Nearest now: Open every hour, 410 m

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.qwe
main.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>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

On NearbyList: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.