UPG Neighbor on a phone: the attribution sits above the sheet, a pick raises the sheet, and the hover tip stays on screen

rega at 390 (b07fc59e0, en + ar, both views): the sheet lowers and raises as it should, but
- the map's attribution control sat under the sheet, lowered or raised, in all four cases. On a phone the map's bottom control corners now sit above the sheet: 55% + 6 px when it is up, 62 px when it is down;
- a pick with the list lowered moved the map but showed no detail, because the detail is in the sheet. A pick (a group, a neighbour dot, a bridge language, a group opened from Campaigns) now raises the sheet FIRST, so the frame is computed for the sheet that shows the detail;
- the hover tip was cut off at the right edge in ar ("Ko…"): it opened to the right of the point wherever the point was, and after a tap it stayed where the tap had been while the map flew to the pick. It now opens toward the side with more room, is at most 45% of the map wide, and clears when the map moves.
This commit is contained in:
Doxa Maps 2026-09-29 09:26:21 +00:00
parent 816aa0e0fc
commit 3ad1a8fc37
2 changed files with 47 additions and 11 deletions

View file

@ -47,9 +47,10 @@ const list = computed(() => {
})
const active = computed(() => data.value?.bridges.find((b) => b.lang === nbState.lang) || null)
function pick(b) { nbState.lang = b.lang }
// A pick raises the phone sheet first, so its detail is in view and the frame is computed for it.
function pick(b) { nbState.sheetDown = false; nbState.lang = b.lang }
function back() { nbState.lang = null }
function openGroup(u) { nbState.pgid = u.pgid; nbState.view = 'mobilize' }
function openGroup(u) { nbState.sheetDown = false; nbState.pgid = u.pgid; nbState.view = 'mobilize' }
// Where to run the campaign: believers per country, largest first.
const audience = computed(() => {
@ -93,6 +94,15 @@ const fc = (features) => ({ type: 'FeatureCollection', features })
// Hover label: after a language is picked, its believers (green) and the unreached they reach (red)
// were anonymous until clicked, and a green dot could not be clicked at all. Same label as Mobilize.
const hover = ref(null) // { x, y, text }
// The tip opens toward the side with more room, so near an edge it is never cut off (rega saw "Ko…"
// at the right edge in ar); max-width keeps it inside half the map on a phone. A tip is stale as soon as
// the map moves (a tap, then the fly to the pick), so moving clears it.
const hoverStyle = computed(() => {
const h = hover.value
if (!h) return {}
const w = mapContainer.value?.clientWidth || 0
return h.x > w / 2 ? { right: (w - h.x + 12) + 'px', top: h.y + 12 + 'px' } : { left: h.x + 12 + 'px', top: h.y + 12 + 'px' }
})
const pt = (g) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [g.lng, g.lat] }, properties: { id: g.pgid, pop: g.pop, b: g.believers } })
function setSrc(m, id, d) { const s = m.getSource(id); if (s) s.setData(d); else m.addSource(id, { type: 'geojson', data: d }) }
let repaintPending = false
@ -161,6 +171,7 @@ onMounted(async () => {
const b = data.value?.bridges.find((x) => x.lang === ev.features?.[0]?.properties?.lang)
if (b) pick(b)
})
m.on('movestart', () => { hover.value = null })
for (const id of ['cp-u', 'cp-r', 'cp-b']) {
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mouseleave', id, () => { m.getCanvas().style.cursor = '' })
@ -217,22 +228,29 @@ useShadowStyles(`
.cp-line { display:flex; justify-content:space-between; gap:8px; font-size:12.5px; padding:5px 0; border-bottom:1px solid #f1f3f6; }
.cp-line button { border:0; background:none; padding:0; font:inherit; color:${NAVY}; cursor:pointer; text-align:start; }
.cp-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.cp-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cp-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:min(260px, 45%); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cp-maperr { position:absolute; inset-inline-end:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
.cp-handle { display:none; align-items:center; justify-content:center; gap:8px; width:100%; min-height:40px; border:0; border-bottom:1px solid #eef0f4;
background:#fff; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; flex:0 0 auto; }
.cp-handle::before { content:''; width:36px; height:4px; border-radius:2px; background:#c9d1dd; }
.cp-handle:focus-visible { outline:2px solid ${NAVY}; outline-offset:-2px; }
@media (max-width:760px) { .cp-panel { top:auto; inset-inline:6px; bottom:6px; width:auto; height:55%; }
.cp-panel.down { height:56px; } .cp-panel.down > :not(.cp-handle) { display:none; } .cp-handle { display:flex; } }
.cp-panel.down { height:56px; } .cp-panel.down > :not(.cp-handle) { display:none; } .cp-handle { display:flex; }
/* The map's own bottom controls (the attribution) sit ABOVE the sheet, never under it:
55% of the height when the sheet is up, a 56 px bar when it is down, both 6 px off the bottom. */
.cp-root .maplibregl-ctrl-bottom-left, .cp-root .maplibregl-ctrl-bottom-right,
.cp-root .mapboxgl-ctrl-bottom-left, .cp-root .mapboxgl-ctrl-bottom-right { bottom:calc(55% + 6px); }
.cp-root.sheet-down .maplibregl-ctrl-bottom-left, .cp-root.sheet-down .maplibregl-ctrl-bottom-right,
.cp-root.sheet-down .mapboxgl-ctrl-bottom-left, .cp-root.sheet-down .mapboxgl-ctrl-bottom-right { bottom:62px; }
}
`, 'upgn-campaigns')
</script>
<template>
<div class="cp-root">
<div class="cp-root" :class="{ 'sheet-down': nbState.sheetDown }">
<div ref="mapContainer" class="cp-map"></div>
<div v-if="mapError" class="cp-maperr" role="status">{{ mapError }}</div>
<div v-if="hover" class="cp-hover" :style="{ left: hover.x + 12 + 'px', top: hover.y + 12 + 'px' }">{{ hover.text }}</div>
<div v-if="hover" class="cp-hover" :style="hoverStyle">{{ hover.text }}</div>
<aside class="cp-panel" :class="{ down: nbState.sheetDown }">
<button type="button" class="cp-handle" :aria-expanded="nbState.sheetDown ? 'false' : 'true'" @click="toggleSheet">{{ nbState.sheetDown ? $t('neighbor.mob.sheetShow') : $t('neighbor.mob.sheetHide') }}</button>

View file

@ -85,7 +85,9 @@ const byLevel = computed(() => [1, 2, 3].map((e) => ({ e, list: neighbors.value.
const believersNear = computed(() => neighbors.value.reduce((a, n) => a + n.g.believers, 0))
const peopleReachable = computed(() => neighbors.value.reduce((a, n) => a + n.g.pop, 0))
function select(g) { if (g) { nbState.pgid = g.pgid; query.value = '' } }
// A pick raises the phone sheet first, so the frame below is computed for the sheet that will show
// the pick's detail (rega, 390: a pick with the list lowered showed only a tooltip).
function select(g) { if (g) { nbState.sheetDown = false; nbState.pgid = g.pgid; query.value = '' } }
// The neighbor in focus: a dot click picks its card, a card click flies to its dot. Both ways, so the
// map and the brief never disagree about which neighbor you are looking at.
@ -93,6 +95,15 @@ const activeId = ref(null)
// Hover label: 7,000 red dots are anonymous until clicked, so name the one under the pointer.
const hover = ref(null) // { x, y, text }
function hoverAt(ev, text) { hover.value = text ? { x: ev.point.x, y: ev.point.y, text } : null }
// The tip opens toward the side with more room, so near an edge it is never cut off (rega saw "Ko…"
// at the right edge in ar); max-width keeps it inside half the map on a phone. A tip is stale as soon as
// the map moves (a tap, then the fly to the pick), so moving clears it.
const hoverStyle = computed(() => {
const h = hover.value
if (!h) return {}
const w = mapContainer.value?.clientWidth || 0
return h.x > w / 2 ? { right: (w - h.x + 12) + 'px', top: h.y + 12 + 'px' } : { left: h.x + 12 + 'px', top: h.y + 12 + 'px' }
})
// A big reached group can be the nearest church for 1,000+ unreached groups: render 30 cards per level
// until the reader asks for the rest, so a click never builds a thousand-card DOM.
const CARD_CAP = 30
@ -371,7 +382,7 @@ onMounted(async () => {
if (!id) return
if (f.layer.id === 'nb-n') {
const n = neighbors.value.find((x) => x.g.pgid === id)
if (n) focusNeighbor(n, false)
if (n) { nbState.sheetDown = false; focusNeighbor(n, false) }
return
}
const g = data.value?.groups.find((x) => x.pgid === id)
@ -379,6 +390,7 @@ onMounted(async () => {
})
const byId = (id) => data.value?.groups.find((g) => g.pgid === id)
const label = (g) => g && `${g.name} · ${g.country} · ${g.langName}`
m.on('movestart', () => { hover.value = null })
for (const id of ['nb-u', 'nb-r', 'nb-n']) {
m.on('mouseenter', id, () => { m.getCanvas().style.cursor = 'pointer' })
m.on('mousemove', id, (ev) => hoverAt(ev, label(byId(ev.features?.[0]?.properties?.id))))
@ -449,7 +461,7 @@ useShadowStyles(`
.mb-same { font:700 10px/1 inherit; color:#7a3e00; background:#fdeedd; border-radius:999px; padding:3px 6px; margin-inline-start:4px; }
.mb-note { padding:14px; color:#6b7280; font-size:13px; line-height:1.45; }
.mb-maperr { position:absolute; inset-inline-end:12px; top:12px; z-index:15; background:#fff; border:1px solid #d9dee6; border-radius:8px; padding:8px 12px; font-size:13px; color:#6b7280; }
.mb-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mb-hover { position:absolute; z-index:25; pointer-events:none; background:rgba(0,27,68,.92); color:#fff; font-size:12px; line-height:1.3; padding:5px 8px; border-radius:6px; max-width:min(260px, 45%); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mb-handle { display:none; align-items:center; justify-content:center; gap:8px; width:100%; min-height:40px; border:0; border-bottom:1px solid #eef0f4;
background:#fff; color:${NAVY}; font:600 12px/1 inherit; cursor:pointer; flex:0 0 auto; }
.mb-handle::before { content:''; width:36px; height:4px; border-radius:2px; background:#c9d1dd; }
@ -462,15 +474,21 @@ useShadowStyles(`
.mb-panel.down > :not(.mb-handle) { display:none; }
.mb-handle { display:flex; }
.mb-legend { top:8px; bottom:auto; inset-inline-end:8px; }
/* The map's own bottom controls (the attribution) sit ABOVE the sheet, never under it:
55% of the height when the sheet is up, a 56 px bar when it is down, both 6 px off the bottom. */
.mb-root .maplibregl-ctrl-bottom-left, .mb-root .maplibregl-ctrl-bottom-right,
.mb-root .mapboxgl-ctrl-bottom-left, .mb-root .mapboxgl-ctrl-bottom-right { bottom:calc(55% + 6px); }
.mb-root.sheet-down .maplibregl-ctrl-bottom-left, .mb-root.sheet-down .maplibregl-ctrl-bottom-right,
.mb-root.sheet-down .mapboxgl-ctrl-bottom-left, .mb-root.sheet-down .mapboxgl-ctrl-bottom-right { bottom:62px; }
}
`, 'upgn-mobilize')
</script>
<template>
<div class="mb-root">
<div class="mb-root" :class="{ 'sheet-down': nbState.sheetDown }">
<div ref="mapContainer" class="mb-map"></div>
<div v-if="mapError" class="mb-maperr" role="status">{{ mapError }}</div>
<div v-if="hover" class="mb-hover" :style="{ left: hover.x + 12 + 'px', top: hover.y + 12 + 'px' }">{{ hover.text }}</div>
<div v-if="hover" class="mb-hover" :style="hoverStyle">{{ hover.text }}</div>
<div class="mb-legend" :aria-label="$t('neighbor.mob.legend')">
<div><span class="mb-dot" :style="{ background: UNREACHED }"></span>{{ $t('neighbor.mob.legendUnreached') }}</div>