diff --git a/library/components/maps/RegionCountryMap.vue b/library/components/maps/RegionCountryMap.vue
index ba0c8459e..7fbdeacf6 100644
--- a/library/components/maps/RegionCountryMap.vue
+++ b/library/components/maps/RegionCountryMap.vue
@@ -258,9 +258,16 @@ function pinExpressions() {
// visible loss of contrast and buys nothing when nothing is being de-emphasised yet.
// The HOVER state is deliberately untouched — dimming the rest to 0.25 is the design saying
// "these pins are one group", which is a taste call rather than a legibility defect.
+ // ★ SMALLER BELOW ZOOM 1 (2026-09-30: on a phone the globe sits near zoom -0.5 and the dots, clamped at their
+ // zoom-2 size, read as far too large). Measured: the desktop chooser sits near zoom 1.1, so every stop from zoom
+ // 1 up keeps the old size and the desktop keeps its dots; below zoom 1 they shrink to about half.
radius: lit
- ? ['case', isLit, ['interpolate', ['linear'], ['zoom'], 2, 4.5, 6, 8], ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 5.5]]
- : ['interpolate', ['linear'], ['zoom'], 2, 4, 6, 7.5],
+ // ZOOM AT THE TOP, the case inside each stop: MapLibre allows ONE zoom interpolate per expression, at the
+ // top, so ['case', isLit, interpolate, interpolate] does not parse and a lit map never got these sizes.
+ ? ['interpolate', ['linear'], ['zoom'], -1, ['case', isLit, 2.8, 1.6], 0.5, ['case', isLit, 3.2, 2], 1, ['case', isLit, 4.5, 3], 2, ['case', isLit, 4.5, 3], 6, ['case', isLit, 8, 5.5]]
+ : ['interpolate', ['linear'], ['zoom'], -1, 2, 0.5, 2.5, 1, 4, 2, 4, 6, 7.5],
+ // THE TAP TARGET: the invisible rcm-pins-hit twin (addLayers), at least 7 px at every zoom.
+ hitRadius: HIT_RADIUS,
}
}
// DX-life's two modes, unchanged (CountryMap.vue:218-223).
@@ -274,13 +281,19 @@ function pinExpressions() {
opacity: ['case', ['get', 'inCountry'], 1, 0.75],
strokeOpacity: 1, // theirs, unchanged (CountryMap.vue) — only the chooser's dim is ours
radius: ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
+ hitRadius: HIT_RADIUS,
}
}
+// ★ PIN TAPS WERE HARD TO LAND ON A PHONE ("not very responsive"): the tap target was the drawn dot, 3-4 px of
+// radius at the zooms a phone shows. The invisible twin takes at least 7 px at every zoom (the size UPG-100's pins
+// settled on), growing with the dots at street zoom. Where pins crowd, the pin drawn on top wins the tap.
+const HIT_RADIUS = ['interpolate', ['linear'], ['zoom'], -1, 7, 2, 7, 6, 9]
+
function addLayers() {
const m = map.value
if (!m) return
- const { color: pinColor, sortKey: pinSortKey, opacity: pinOpacity, radius: pinRadius, strokeOpacity: pinStrokeOpacity } = pinExpressions()
+ const { color: pinColor, sortKey: pinSortKey, opacity: pinOpacity, radius: pinRadius, strokeOpacity: pinStrokeOpacity, hitRadius: pinHitRadius } = pinExpressions()
if (!m.getSource(SRC_COUNTRIES)) {
// ── COUNTRY POLYGONS, PER PROVIDER ────────────────────────────────────────────────────────
@@ -358,9 +371,21 @@ function addLayers() {
'circle-stroke-opacity': pinStrokeOpacity,
},
})
- m.on('click', 'rcm-pins', onPinClick)
- m.on('mouseenter', 'rcm-pins', () => { m.getCanvas().style.cursor = 'pointer' })
- m.on('mouseleave', 'rcm-pins', () => { m.getCanvas().style.cursor = '' })
+ // ★ AN INVISIBLE HIT TWIN (the pattern UPG-100's pins use): the engines hit-test a circle at its radius +
+ // stroke, so shrinking the drawn dot would shrink the tap target with it. The twin keeps the old ramps, the
+ // same sort key (so a tap picks the same pin as before), and both opacities 0, which skips the draw but
+ // keeps the features queryable. The click and the cursor bind to it.
+ m.addLayer({
+ id: 'rcm-pins-hit', type: 'circle', source: SRC_PINS,
+ layout: { 'circle-sort-key': pinSortKey },
+ paint: { 'circle-radius': pinHitRadius, 'circle-stroke-width': 1, 'circle-opacity': 0, 'circle-stroke-opacity': 0 },
+ })
+ m.on('click', 'rcm-pins-hit', onPinClick)
+ m.on('click', 'rcm-focus-fill', onCountryClick)
+ m.on('mouseenter', 'rcm-focus-fill', () => { if (props.selectOnPinClick) m.getCanvas().style.cursor = 'pointer' })
+ m.on('mouseleave', 'rcm-focus-fill', () => { if (props.selectOnPinClick) m.getCanvas().style.cursor = '' })
+ m.on('mouseenter', 'rcm-pins-hit', () => { m.getCanvas().style.cursor = 'pointer' })
+ m.on('mouseleave', 'rcm-pins-hit', () => { m.getCanvas().style.cursor = '' })
}
}
@@ -369,13 +394,9 @@ let popup = null
function onPinClick(e) {
const f = e.features && e.features[0]
if (!f) return
- // CHOOSER: a pin click is a CHOICE, not a popup — the whole point of the state is to pick a
- // subject. On a cloned page it stays their popup, because that is what their page does.
- if (props.selectOnPinClick) {
- const p = f.properties || {}
- emit('pick-country', { code: p.code || '', region: p.region || '', name: p.name || '' })
- return
- }
+ // ★ A PIN TAP OPENS ITS POPUP, everywhere (2026-09-30: on the all-regions map a pin tap jumped to the country's
+ // dashboard, when the reader wanted the people group). In the chooser, a COUNTRY tap picks the country instead
+ // (onCountryClick).
// ★ THE ENGINE THAT BUILT THIS MAP, not the Mapbox global. This read `window.mapboxgl` and
// returned SILENTLY when it was absent. Under provider=maplibre it worked only because every
// leaf
statically loads mapbox-gl — so a MapLibre map was opening a MAPBOX popup. studio
@@ -393,6 +414,16 @@ function onPinClick(e) {
requestAnimationFrame(() => panPopupIntoView(f.geometry.coordinates))
}
+// The CHOOSER picks a country by its shape. A tap that lands on a pin is the pin's (its popup), so the country
+// only answers a tap with no pin under it. The prop keeps its old name: it means "this map is the chooser".
+function onCountryClick(e) {
+ if (!props.selectOnPinClick || !map.value) return
+ if (map.value.getLayer('rcm-pins-hit') && map.value.queryRenderedFeatures(e.point, { layers: ['rcm-pins-hit'] }).length) return
+ const f = e.features && e.features[0]
+ const code = f && f.properties && f.properties.iso_3166_1_alpha_3
+ if (code) emit('pick-country', { code: String(code).toUpperCase(), region: '', name: (f.properties.name || '') })
+}
+
// Their popup offset, as one constant: the pan arithmetic has to use the SAME number the popup was
// built with, and two literals drift.
const POPUP_OFFSET = 12
@@ -768,12 +799,16 @@ function applySubject() {
if (src) src.setData(geojson.value)
// Re-apply the MODE, not just the data — see pinExpressions().
if (m.getLayer('rcm-pins')) {
- const { color, sortKey, opacity, radius, strokeOpacity } = pinExpressions()
+ const { color, sortKey, opacity, radius, strokeOpacity, hitRadius } = pinExpressions()
m.setPaintProperty('rcm-pins', 'circle-color', color)
m.setPaintProperty('rcm-pins', 'circle-opacity', opacity)
m.setPaintProperty('rcm-pins', 'circle-stroke-opacity', strokeOpacity)
m.setPaintProperty('rcm-pins', 'circle-radius', radius)
m.setLayoutProperty('rcm-pins', 'circle-sort-key', sortKey)
+ if (m.getLayer('rcm-pins-hit')) {
+ m.setPaintProperty('rcm-pins-hit', 'circle-radius', hitRadius)
+ m.setLayoutProperty('rcm-pins-hit', 'circle-sort-key', sortKey)
+ }
}
for (const id of ['rcm-focus-fill', 'rcm-focus-outline']) {
if (m.getLayer(id)) m.setFilter(id, countryFilter())
diff --git a/library/components/poster/PosterDialog.vue b/library/components/poster/PosterDialog.vue
index 730ad0d49..a34ae2bac 100644
--- a/library/components/poster/PosterDialog.vue
+++ b/library/components/poster/PosterDialog.vue
@@ -32,9 +32,10 @@
+
{{ T('poster.phoneLimit', "On a phone, the largest sizes are off: a phone can't draw a poster that big. Use a computer for them.") }}