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.") }}