MapSheet
A list in a sheet over a Leaflet map, each synced to the other, pins and rows, with the sheet dragged down to see more of the map; on a tablet, a list beside it.
Demo
In Lisbon
17 of 17 in viewPicked: nothing yet. Stop: half.
Get it
It needs theme.qwe, as every component does; 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/map_sheet.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/map_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/map_sheet.qwe
let map_sheet_demo_places = [ { id: 1, name: "A Brasileira", note: "Cafe, Chiado", lat: 38.7106, lng: -9.1415 }, { id: 2, name: "Time Out Market", note: "Food hall, Cais do Sodre", lat: 38.7070, lng: -9.1458 }, { id: 3, name: "Miradouro de Santa Luzia", note: "Viewpoint, Alfama", lat: 38.7119, lng: -9.1300 }, { id: 4, name: "Se de Lisboa", note: "Cathedral", lat: 38.7097, lng: -9.1331 }, { id: 5, name: "Castelo de Sao Jorge", note: "Castle", lat: 38.7139, lng: -9.1335 }, { id: 6, name: "Pharmacy on Rossio", note: "Open all night", lat: 38.7139, lng: -9.1383 }, { id: 7, name: "Elevador de Santa Justa", note: "Lift", lat: 38.7121, lng: -9.1394 }, { id: 8, name: "Praca do Comercio", note: "Square", lat: 38.7075, lng: -9.1364 }, { id: 9, name: "Museu do Fado", note: "Museum, Alfama", lat: 38.7110, lng: -9.1270 }, { id: 10, name: "Feira da Ladra", note: "Market, Tuesdays and Saturdays", lat: 38.7160, lng: -9.1245 }, { id: 11, name: "Jardim da Estrela", note: "Garden", lat: 38.7145, lng: -9.1600 }, { id: 12, name: "Lost its place", note: "Somewhere" }, { id: 13, name: "Igreja de Sao Roque", note: "Church", lat: 38.7136, lng: -9.1437 }, { id: 14, name: "Miradouro de Sao Pedro", note: "Viewpoint", lat: 38.7155, lng: -9.1440 }, { id: 15, name: "Convento do Carmo", note: "Ruins", lat: 38.7120, lng: -9.1405 }, { id: 16, name: "Panteao Nacional", note: "Monument", lat: 38.7150, lng: -9.1250 }, { id: 17, name: "Casa dos Bicos", note: "Museum", lat: 38.7084, lng: -9.1330 }, { id: 18, name: "Mercado de Campo de Ourique", note: "Food hall", lat: 38.7166, lng: -9.1670 },]component MapSheetDemoLive() { state chosen = "nothing yet" state stop = "half" state from_page = null state shown = true <div.demo-stack> <div.demo-phone> {shown && <MapSheet title="In Lisbon" label="Places in Lisbon" places={map_sheet_demo_places} selected={from_page} on_pick={(p) => { chosen = p.name }} on_detent={(n) => { stop = n }}/>} </div> <div.demo-row> <button.map-sheet-demo-page type="button" style="min-height: 44px" @click={() => { from_page = 10 }}>Mark the market from the page</button> <button.map-sheet-demo-toggle type="button" style="min-height: 44px" @click={() => { shown = !shown }}>Take it away, or bring it back</button> <span.demo-note>Picked: <span class="map-sheet-demo-chosen">{chosen}</span>. Stop: <span class="map-sheet-demo-stop">{stop}</span>.</span> </div> </div>}view MapSheetDemo() = <MapSheetDemoLive/>Source
ui/map_sheet.qwe
// MapSheet: a map with a list of its places in a sheet over it, as a phone's map apps have: each// follows the other. Pressing a pin marks its row and scrolls to it; pressing a row moves the// map so that its pin stands in the middle of what the sheet leaves showing. Moving the map// dims the rows whose pins have gone out of view. The sheet rests at three heights, and// dragging it down shows more of the map; a pin pressed stays clear of it. On a tablet, or in// any pane 600 px wide or more, the list stands down the left side instead, and the map// takes the rest. The map is Leaflet's, on tiles from the network: with no script or no signal// the list still works, and it says so.//// import ui/map_sheet.qwe use MapSheet//// <div style="height: 100dvh">// <MapSheet title="Cafes" places={[{ id: 1, name: "A Brasileira", note: "Cafe, open till 2", lat: 38.7106, lng: -9.1415 }]}// on_pick={(p) => { chosen = p.id }}/>// </div>//// It fills the height it is given. places: a list of { id, name, note?, lat, lng (or lon) }. Each// pin has the number of its row; a place with no usable position is listed, and has no pin.// selected: the id marked, and a new value marks another as a press would. center: [lat, lng]// to start at; left out, the map fits every pin into what the sheet leaves. zoom: with// center (14). title: the sheet's heading ("Places"). start: "peek", "half" (default) or "full".// peek: how tall "peek" is, in pixels (116). label: what screen readers call the map ("Map").// tiles, attribution: where the tiles come from (OpenStreetMap's). on_pick(place): a pin or row// pressed, inside one of your components. on_detent(name): the sheet came to rest at another stop.//// The equal of the drag is the handle, a button: a tap goes to the next stop, and ArrowUp and// ArrowDown go up and down one. Pins take Tab and Enter, rows are buttons, and + and - zoom.//// map_sheet_snap, map_sheet_rubber, map_sheet_in_view and map_sheet_aim are the working.import ui/theme.qwe use ui_clsclient "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" use Llet map_sheet_css = css`/* set elsewhere: leaflet- (Leaflet's script sets them) */.ui-mapsheet { position: relative; isolation: isolate; box-sizing: border-box; height: 100%; min-height: 320px; overflow: hidden; container-type: size; container-name: ui-mapsheet; background: var(--ui-subtle); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-mapsheet-map { position: absolute; inset: 0; z-index: 0; }.ui-mapsheet .leaflet-container { background: var(--ui-subtle); font: 11px/1.4 var(--ui-font); }.ui-mapsheet-pin { background: none; border: 0; }.ui-mapsheet-pin > span { position: absolute; left: 7px; top: 7px; box-sizing: border-box; display: grid; place-items: center; width: 30px; height: 30px; border: 2px solid #fff; border-radius: 50%; background: var(--ui-accent); color: var(--ui-accent-fg); font: 700 13px/1 var(--ui-font); box-shadow: 0 1px 4px rgb(0 0 0 / .45); transition: transform .12s;}.ui-mapsheet-pin.ui-mapsheet-on > span { background: var(--ui-fg); color: var(--ui-bg); transform: scale(1.25); }.ui-mapsheet-pin:focus-visible { outline: none; }.ui-mapsheet-pin:focus-visible > span { box-shadow: var(--ui-focus); }.ui-mapsheet-tools { position: absolute; right: max(8px, env(safe-area-inset-right)); top: max(8px, env(safe-area-inset-top)); z-index: 5; display: grid; gap: 6px; }.ui-mapsheet-tool { box-sizing: border-box; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 20px/1 var(--ui-font); cursor: pointer; box-shadow: var(--ui-shadow); -webkit-tap-highlight-color: transparent;}.ui-mapsheet-tool:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-mapsheet-nomap { position: absolute; left: 12px; right: 64px; top: 12px; z-index: 5; margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font-size: 14px; box-shadow: var(--ui-shadow); }.ui-mapsheet-sheet { position: absolute; inset: 12px 0 0 0; z-index: 10; display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden; border: 1px solid var(--ui-border); border-bottom: 0; background: var(--ui-surface); border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0; box-shadow: var(--ui-shadow-lg), 0 160px 0 0 var(--ui-surface); will-change: transform; transform: translateY(var(--ui-mapsheet-y)); transition: transform .32s cubic-bezier(.22, 1, .36, 1);}.ui-mapsheet-grip { display: grid; place-items: center; flex: none; width: 100%; height: 44px; padding: 0; border: 0; background: transparent; cursor: grab; touch-action: none; -webkit-touch-callout: none; user-select: none;}.ui-mapsheet-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-mapsheet-grip:focus-visible { outline: none; }.ui-mapsheet-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-mapsheet-head { flex: none; display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-top: -8px; padding: 0 16px 8px; touch-action: none; -webkit-touch-callout: none; user-select: none; }.ui-mapsheet-head h2 { margin: 0; font-size: 17px; font-weight: 600; line-height: 1.3; }.ui-mapsheet-count { color: var(--ui-muted); font-size: 13px; white-space: nowrap; }.ui-mapsheet-body { position: relative; flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 0 8px calc(16px + env(safe-area-inset-bottom) + var(--ui-mapsheet-hide, 0px)); }.ui-mapsheet-list { margin: 0; padding: 0; list-style: none; }.ui-mapsheet-row { box-sizing: border-box; display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: center; gap: 0 10px; width: 100%; min-height: 56px; padding: 6px 8px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-mapsheet-row:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-mapsheet-row[aria-current="true"] { background: var(--ui-accent-soft); }.ui-mapsheet-row[data-seen="no"] { opacity: .55; }.ui-mapsheet-num { grid-row: 1 / span 2; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--ui-subtle); font-size: 12px; font-weight: 700; }.ui-mapsheet-row[aria-current="true"] .ui-mapsheet-num { background: var(--ui-fg); color: var(--ui-bg); }.ui-mapsheet-name { font-weight: 600; overflow-wrap: anywhere; }.ui-mapsheet-note { color: var(--ui-muted); font-size: 13px; overflow-wrap: anywhere; }.ui-mapsheet-empty { padding: 20px 8px; color: var(--ui-muted); text-align: center; }/* With the room, a card down the side, at its full height: nothing to drag. */@container ui-mapsheet (min-width: 600px) { .ui-mapsheet-sheet { inset: 12px auto 12px max(12px, env(safe-area-inset-left)); width: 340px; border-bottom: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 8px); box-shadow: var(--ui-shadow-lg); transform: none; transition: none; } .ui-mapsheet-grip { display: none; } .ui-mapsheet-head { margin-top: 0; padding-top: 12px; touch-action: auto; } .ui-mapsheet-body { --ui-mapsheet-hide: 0px; }}@media (hover: hover) { .ui-mapsheet-row:hover { background: var(--ui-subtle); } }@media (prefers-reduced-motion: reduce) { .ui-mapsheet-sheet, .ui-mapsheet-pin > span { transition: none; } }`fn map_sheet_num(x) = (type_of(x) == "int" || type_of(x) == "float" || type_of(x) == "dec") && math.isfinite(float(x))// { lat, lng } from a pair or a map, or null if it is not a place on Earth.fn map_sheet_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 !map_sheet_num(lat) || !map_sheet_num(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180 { return null } { lat: float(lat), lng: float(lng) }}// The places fit for showing: numbered as listed, with a position or null.fn map_sheet_rows(places) = (type_of(places) == "list" ? places : []) | filter((p) => type_of(p) == "map") | enumerate() | map((pair) => { id: pair[1].id ?? pair[0], n: pair[0] + 1, place: pair[1], at: map_sheet_point(pair[1]) })// A finger as it goes: where it began, which axis it took (null until it has moved `slop`// pixels) and its last few places, to tell how fast it was going.fn map_sheet_track(x: num, y: num, at: num) = { x0: x, y0: y, x: x, y: y, lock: null, samples: [[at, y]] }fn map_sheet_track_move(track, x: num, y: num, at: num, slop: num = 6) { let dx = abs(x - track.x0) let dy = abs(y - track.y0) let lock = track.lock if lock == null && max([dx, dy]) >= slop { lock = dx > dy ? "x" : "y" } { x0: track.x0, y0: track.y0, x: x, y: y, lock: lock, samples: slice(track.samples, -5, len(track.samples)) + [[at, y]] }}// Pixels a millisecond over the last tenth of a second, more than nothing going down.fn map_sheet_speed(track, at: num) { let s = track.samples let last = s[len(s) - 1] if at - last[0] > 120 { return 0 } let recent = s | filter((p) => last[0] - p[0] <= 100) let first = len(recent) > 1 || len(s) < 2 ? recent[0] : s[len(s) - 2] let took = last[0] - first[0] took <= 0 ? 0 : (last[1] - first[1]) / took}// How far something goes when pulled `over` pixels past its end: it keeps moving and never// reaches `limit`. It is the curve iOS's scroll views use.fn map_sheet_rubber(over: num, limit: num = 90) { if over <= 0 { return 0 } round((1 - 1 / (over * 0.55 / limit + 1)) * limit, 2)}// Which stop a sheet let go at height `h` rests at: a flick goes on to the next stop that way,// however short it was; otherwise the stop nearest to where it would have coasted to.fn map_sheet_snap(heights, h: num, speed: num) { let count = len(heights) if abs(speed) >= 0.5 { let found = speed > 0 ? range(count) | filter((i) => heights[i] < h) : range(count) | filter((i) => heights[i] > h) if speed > 0 { return len(found) > 0 ? found[len(found) - 1] : 0 } return len(found) > 0 ? found[0] : count - 1 } let coast = h - speed * 180 let best = 0 for i in range(count) { if abs(heights[i] - coast) < abs(heights[best] - coast) { best = i } } best}// Heights of the three stops in a sheet that can be `full` pixels tall.fn map_sheet_heights(full: num, peek: num) { let half = round(full / 2) [max([44, min([peek, half])]), half, full]}fn map_sheet_first_y(name: str, peek: int) = name == "full" ? "0px" : (name == "half" ? "50%" : `calc(100% - ${peek}px)`)// How far the middle of the map must be from a pin's pixel for the pin to stand in the middle// of what can be seen: the map's size w, h and the rectangle showing.fn map_sheet_aim(w: num, h: num, left: num, top: num, right: num, bottom: num) = { x: w / 2 - (left + right) / 2, y: h / 2 - (top + bottom) / 2 }// The ids of the rows whose pins lie inside a box of the map.fn map_sheet_in_view(rows, north: num, south: num, west: num, east: num) = rows | filter((r) => r.at != null && r.at.lat <= north && r.at.lat >= south && r.at.lng >= west && r.at.lng <= east) | map((r) => r.id)// Of pins { id, x, y } on the screen, the id of the nearest to a touch. Pins are 44 px to be// pressed, so two close together overlap, and the one on top is not the one a finger aimed at.fn map_sheet_nearest(points, x: num, y: num) { let best = null let best_d = 0 for p in points { let d = (p.x - x) * (p.x - x) + (p.y - y) * (p.y - y) if best == null || d < best_d { best = p.id best_d = d } } best}let map_sheet_stops = ["peek", "half", "full"]fn map_sheet_stop_label(i: int) = i == 0 ? "Peek" : (i == 1 ? "Half height" : "Full height")component MapSheet(places = [], selected = null, center = null, zoom: int = 14, title: str = "Places", start: str = "half", peek: int = 116, label: str = "Map", tiles: str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: str = "© OpenStreetMap", on_pick = null, on_detent = null, class: str? = null) { style global map_sheet_css let rows = map_sheet_rows(places) let mapped = rows | filter((r) => r.at != null) state host = null state sheet = null state body = null state leaflet = null state layer = null state marks = [] // { id, pin }: Leaflet's own, to mark one without drawing them all state failed = false state picked = selected state seen_selected = selected state in_view = null // the ids in sight, null until the map has said state at = index_of(map_sheet_stops, start) ?? 1 state heights = [] state wide = false state fitted = false state track = null state zone = null // what the finger started on: "handle", "body", then "drag" once it moves the sheet state from = 0 state now_h = 0 state moved = false state scrolling = false // the list has this finger; if it gets to the list's top, the sheet takes it let calm = () => js("matchMedia", "(prefers-reduced-motion: reduce)").matches // What the sheet leaves showing, in the map's own pixels. let showing = () => { let box = host.getBoundingClientRect() if wide { let side = sheet.getBoundingClientRect() { left: side.right - box.left, top: 0, right: box.width, bottom: box.height } } else { { left: 0, top: 0, right: box.width, bottom: box.height - now_h } } } let look = () => { if leaflet != null { let s = showing() let a = leaflet.containerPointToLatLng([s.left, s.top]) let b = leaflet.containerPointToLatLng([s.right, max([s.top + 1, s.bottom])]) in_view = map_sheet_in_view(rows, a.lat, b.lat, a.lng, b.lng) } } let rest = (h: num) => { let full = sheet.offsetHeight now_h = h sheet.style.setProperty("--ui-mapsheet-y", `${full - h}px`) sheet.style.setProperty("--ui-mapsheet-hide", `${full - h}px`) sheet.style.removeProperty("transform") sheet.style.removeProperty("transition") } let aim_at = (id) => { let r = find(mapped, (x) => x.id == id) if leaflet != null && r != null { let s = showing() let size = leaflet.getSize() let off = map_sheet_aim(size.x, size.y, s.left, s.top, s.right, s.bottom) let z = leaflet.getZoom() let p = leaflet.project([r.at.lat, r.at.lng], z) leaflet.panTo(leaflet.unproject([p.x + off.x, p.y + off.y], z), { animate: !calm(), duration: 0.25 }) } } let go = (i: int) => { let was = at at = i if heights != [] && !wide { rest(heights[i]) } if was != i { on_detent && on_detent(map_sheet_stops[i]) look() if picked != null { aim_at(picked) } } } let measure = () => { wide = host.offsetWidth >= 600 if wide { sheet.style.removeProperty("--ui-mapsheet-hide") sheet.style.removeProperty("transform") now_h = 0 } else { heights = map_sheet_heights(sheet.offsetHeight, peek) rest(heights[at]) } } let show_row = (id) => { let row = body.querySelector(`[data-id="${str(id)}"]`) if row != null { body.scrollTop = row.offsetTop } } // A press, from a pin or a row or the page: each marks the other. let choose = (id, source: str) => { picked = id let r = find(rows, (x) => x.id == id) if source != "list" { if !wide && at == 0 { go(1) } show_row(id) } else { if !wide && at == 2 { go(1) } } aim_at(id) if source != "page" && r != null { on_pick && on_pick(r.place) } } let fit = () => { if leaflet != null && len(mapped) > 0 { let s = showing() let box = host.getBoundingClientRect() leaflet.fitBounds(mapped | map((r) => [r.at.lat, r.at.lng]), { paddingTopLeft: [s.left + 32, s.top + 32], paddingBottomRight: [box.width - s.right + 32, box.height - s.bottom + 32], maxZoom: 16, animate: false }) } } // Places drawn again when the list changes. if leaflet != null && layer != null { layer.clearLayers() marks = mapped | map((r) => { let name = str(r.place.name ?? "A place") let pin = L.marker([r.at.lat, r.at.lng], { title: name, alt: name, keyboard: true, riseOnHover: false, icon: L.divIcon({ className: "ui-mapsheet-pin", html: `<span>${r.n}</span>`, iconSize: [44, 44], iconAnchor: [22, 22] }) }) pin.on("click", (e) => { // Leaflet's own containerPoint is the pin's, not the finger's. A key press has // no place at all: it means the pin it was on. let touch = e.originalEvent.type == "click" ? leaflet.mouseEventToContainerPoint(e.originalEvent) : null let spots = touch == null ? [] : marks | map((k) => { let c = leaflet.latLngToContainerPoint(k.pin.getLatLng()) { id: k.id, x: c.x, y: c.y } }) choose(touch == null ? r.id : map_sheet_nearest(spots, touch.x, touch.y) ?? r.id, "map") }) layer.addLayer(pin) { id: r.id, pin: pin } }) } // Which pin is marked. A class on Leaflet's own element, so the rest stay as they are. for m in marks { let el = m.pin.getElement() if el != null { el.classList.toggle("ui-mapsheet-on", m.id == picked) m.pin.setZIndexOffset(m.id == picked ? 2000 : 0) } } if selected != seen_selected { seen_selected = selected if leaflet != null || selected == null { choose(selected, "page") } else { picked = selected } } let decide = (e) => { if zone == "body" { let up = track.y < track.y0 let list_owns = up ? at == 2 : body.scrollTop > 0 if list_owns { track = null zone = null scrolling = !up } else { zone = "drag" } } else if zone == "handle" { zone = "drag" } if zone == "drag" { moved = true // A touch event has no pointerId. if e.pointerId != null { sheet.setPointerCapture(e.pointerId) } sheet.style.transition = "none" } } let follow = () => { let want = from - (track.y - track.y0) let ceiling = heights[2] let lowest = heights[0] let h = want if want > ceiling { h = ceiling + map_sheet_rubber(want - ceiling) } if want < lowest { h = lowest - map_sheet_rubber(lowest - want) } now_h = h sheet.style.transform = `translateY(${round(sheet.offsetHeight - h, 2)}px)` } let moving = (e) => { if track != null { track = map_sheet_track_move(track, e.clientX, e.clientY, e.timeStamp) if track.lock == "x" && zone != "drag" { track = null zone = null } if track != null && track.lock == "y" { if zone != "drag" { decide(e) } if zone == "drag" { follow() } } } } let ended = (e) => { if track != null && zone == "drag" { let i = map_sheet_snap(heights, now_h, map_sheet_speed(track, e.timeStamp)) track = null zone = null go(i) } else { track = null zone = null } } let cancelled = () => { if zone == "drag" { rest(heights[at]) } track = null zone = null } let count = len(mapped) let seen_count = in_view != null ? len(in_view) : count // The first height is on the host, not the sheet: a style={...} on what a finger moves is written // again when the page updates, and takes with it what a drag has set on the element. <div class={ui_cls(["ui-mapsheet", class])} style={`--ui-mapsheet-y: ${map_sheet_first_y(start, peek)}`} @mount={(el) => { host = el }}> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/> <div.ui-mapsheet-map role="region" aria-label={label} @mount={(el) => { let c = map_sheet_point(center) // Quarter zooms, so that fitting the pins does not leave them bunched in a corner. let m = L.map(el, { zoomControl: false, keyboard: true, minZoom: 2, maxZoom: 19, zoomSnap: 0.25, inertia: !calm() }) if is_err(m) { failed = true let plain = js_new("ResizeObserver", (_x) => { measure() }) plain.observe(el) return () => { plain.disconnect() } } m.attributionControl.setPrefix(false) if c != null { m.setView([c.lat, c.lng], zoom, { animate: false }) } else { m.setView([0, 0], 2, { animate: false }) } L.tileLayer(tiles, { maxZoom: 19, attribution: escape(attribution) }).addTo(m) layer = L.layerGroup().addTo(m) m.on("moveend", () => { look() }) let watching = js_new("ResizeObserver", (_x) => { measure() m.invalidateSize({ animate: false }) if !fitted { fitted = true if c == null { fit() } } if picked != null { aim_at(picked) } look() }) watching.observe(el) leaflet = m () => { watching.disconnect() m.remove() } }}></div> {failed && <p.ui-mapsheet-nomap role="status">The map could not load. The list still works.</p>} <div.ui-mapsheet-tools> <button.ui-mapsheet-tool.ui-mapsheet-zoom-in type="button" aria-label="Zoom in" @click={() => { leaflet && leaflet.zoomIn(1, { animate: !calm() }) }}>+</button> <button.ui-mapsheet-tool.ui-mapsheet-zoom-out type="button" aria-label="Zoom out" @click={() => { leaflet && leaflet.zoomOut(1, { animate: !calm() }) }}>-</button> <button.ui-mapsheet-tool.ui-mapsheet-all type="button" aria-label="Show every place" @click={fit}> <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg> </button> </div> <section.ui-mapsheet-sheet aria-label={title} @mount={(el) => { sheet = el }} @pointerdown={(e) => { let t = e.target let on_handle = t.closest(".ui-mapsheet-grip, .ui-mapsheet-head") != null let on_body = t.closest(".ui-mapsheet-body") != null moved = false // A mouse in the list selects text and its wheel scrolls. A wide pane has no stops. if !wide && e.button == 0 && (on_handle || (on_body && e.pointerType == "touch")) { track = map_sheet_track(e.clientX, e.clientY, e.timeStamp) zone = on_handle ? "handle" : "body" from = now_h } }} @pointermove={moving} @pointerup={ended} @pointercancel={cancelled} @touchmove={(e) => { // The list got to its top under a finger going down. The browser is // scrolling, so no pointer events come, and only a touch event can stop it. if scrolling && body.scrollTop <= 0 { let f = e.touches[0] scrolling = false track = map_sheet_track(f.clientX, f.clientY, e.timeStamp) zone = "drag" from = now_h moved = true sheet.style.transition = "none" } if track != null && e.touches.length == 1 { if zone != "drag" || !e.cancelable { let f = e.touches[0] track = map_sheet_track_move(track, f.clientX, f.clientY, e.timeStamp) } if track.lock == "y" && zone == "body" { decide(e) } if zone == "drag" { if e.cancelable { e.preventDefault() } else { follow() } } } }} @touchend={(e) => { scrolling = false if track != null && zone == "drag" && !e.cancelable { ended(e) } }}> <button.ui-mapsheet-grip type="button" aria-label={`${map_sheet_stop_label(at)}: go to the next size`} @click={() => { if !moved { go(at == 2 ? 0 : at + 1) } moved = false }} @keydown={(e) => { if e.key == "ArrowUp" { e.preventDefault() if at < 2 { go(at + 1) } } if e.key == "ArrowDown" { e.preventDefault() if at > 0 { go(at - 1) } } }}><span aria-hidden="true"></span></button> <div.ui-mapsheet-head> <h2>{title}</h2> <span.ui-mapsheet-count>{seen_count} of {count} in view</span> </div> <div.ui-mapsheet-body @mount={(el) => { body = el }}> {len(rows) == 0 ? <p.ui-mapsheet-empty>No places to show.</p> : <ol.ui-mapsheet-list> {for r in rows => <li key={r.id}> <button.ui-mapsheet-row type="button" data-id={str(r.id)} aria-current={picked == r.id ? "true" : null} data-seen={in_view != null && r.at != null && !contains(in_view, r.id) ? "no" : null} @click={() => { choose(r.id, "list") }}> <span.ui-mapsheet-num aria-hidden="true">{r.n}</span> <span.ui-mapsheet-name>{r.place.name ?? "A place"}</span> <span.ui-mapsheet-note>{r.at == null ? "No position" : (r.place.note ?? "")}</span> </button> </li>} </ol>} </div> </section> </div>}Related
PeekSheetA sheet that stays over a map or page and grows with a drag, with what is behind it still in use; on a tablet, a card down the side.3 views · 8 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsPinDropperA 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 downloadsRouteStepsTurn-by-turn steps with the current one large, progress, distance left and an ETA from data, moved along by a position, a swipe, buttons or keys.2 views · 7 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On MapSheet: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.