Maps stop flying for readers who ask for reduced motion: useMapFly decides it for every camera move
A reader whose OS asks for reduced motion (prefers-reduced-motion: reduce) still got flying and easing
maps: callers passed { duration: 0 } themselves, and flyToCoords took no options argument, so theirs was
silently dropped. useMapFly now routes every camera move (flyTo, easeTo, fitBounds: 12 call sites)
through one guard: jumpTo, or fitBounds with animate:false, under reduced motion. flyToCoords takes and
honours caller options. check:map-fly-reduced-motion runs the real composable against a recording map,
headless: 7/7 PASS here; 5 of them FAIL on the previous code.
This commit is contained in:
parent
0ca857ff08
commit
1dfd63b631
3 changed files with 81 additions and 13 deletions
42
internal/check-map-fly-reduced-motion.mjs
Normal file
42
internal/check-map-fly-reduced-motion.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-map-fly-reduced-motion — useMapFly honours prefers-reduced-motion for EVERY camera move, headless.
|
||||
*
|
||||
* A reader who asks the OS for reduced motion must not get flying/easing maps (a photosensitivity issue).
|
||||
* useMapFly decides it itself (cam()), so a caller cannot forget it; flyToCoords also honours caller
|
||||
* options (it used to take none, silently dropping a caller's { duration: 0 }).
|
||||
* Runs the real composable against a recording fake map, with matchMedia reporting reduce / no-preference.
|
||||
* USAGE node internal/check-map-fly-reduced-motion.mjs EXIT 0 pass · 1 fail
|
||||
*/
|
||||
let reduce = false
|
||||
globalThis.window = { matchMedia: (q) => ({ matches: reduce && /prefers-reduced-motion:\s*reduce/.test(q) }), devicePixelRatio: 1 }
|
||||
const { useMapFly } = await import('../library/composables/useMapFly.js')
|
||||
const calls = []
|
||||
const map = new Proxy({}, { get: (_, k) => (k === 'getZoom' ? () => 3 : (...a) => { calls.push({ fn: String(k), a }) }) })
|
||||
const fly = useMapFly({ getMap: () => map })
|
||||
let bad = 0
|
||||
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — got ' + JSON.stringify(got)}`) }
|
||||
const run = (fn) => { calls.length = 0; fn(); return calls.slice() }
|
||||
const B = [[0, 0], [10, 10]]
|
||||
|
||||
console.log('MAP-FLY-REDUCED-MOTION: the real useMapFly against a recording map (no browser)')
|
||||
reduce = false
|
||||
let c = run(() => fly.flyTo([1, 2], { zoom: 4 }))
|
||||
check('motion allowed: flyTo animates', c[0]?.fn === 'flyTo', c)
|
||||
c = run(() => fly.fitBounds(B))
|
||||
check('motion allowed: fitBounds eases (duration > 0)', c[0]?.fn === 'fitBounds' && c[0].a[1].duration > 0, c)
|
||||
c = run(() => fly.flyToCoords({ longitude: 1, latitude: 2 }, 5, { duration: 0 }))
|
||||
check('flyToCoords passes caller options through ({ duration: 0 })', c[0]?.fn === 'flyTo' && c[0].a[0].duration === 0 && c[0].a[0].zoom === 5, c)
|
||||
|
||||
reduce = true
|
||||
c = run(() => fly.flyTo([1, 2], { zoom: 4 }))
|
||||
check('reduced motion: flyTo becomes jumpTo', c.length === 1 && c[0].fn === 'jumpTo' && c[0].a[0].zoom === 4, c)
|
||||
c = run(() => fly.flyToCoords({ longitude: 1, latitude: 2 }))
|
||||
check('reduced motion: flyToCoords becomes jumpTo', c.length === 1 && c[0].fn === 'jumpTo', c)
|
||||
c = run(() => fly.fitBounds(B))
|
||||
check('reduced motion: fitBounds is instant (animate:false, duration 0)', c[0]?.fn === 'fitBounds' && c[0].a[1].animate === false && c[0].a[1].duration === 0, c)
|
||||
c = run(() => fly.resetView())
|
||||
check('reduced motion: resetView makes no flyTo/easeTo', !c.some((x) => x.fn === 'flyTo' || x.fn === 'easeTo'), c)
|
||||
|
||||
if (bad) { console.log(`MAP-FLY-REDUCED-MOTION: FAIL — ${bad} case(s).`); process.exit(1) }
|
||||
console.log('MAP-FLY-REDUCED-MOTION: PASS — every camera move in useMapFly is instant under reduced motion; caller options reach flyToCoords.')
|
||||
|
|
@ -15,7 +15,7 @@ import { mapDefaults } from '../constants/mapDefaults.js';
|
|||
*
|
||||
* Includes:
|
||||
* - flyTo(center, options)
|
||||
* - flyToCoords({ longitude, latitude }, zoom?)
|
||||
* - flyToCoords({ longitude, latitude }, zoom?, options?)
|
||||
* - fitBounds(bounds, padding?)
|
||||
* - slowFlyToFamily / slowFlyToRegion (domain-aware helpers used by legend clicks)
|
||||
* - getOffsetCenter — shifts map center to account for a UI panel overlay
|
||||
|
|
@ -40,6 +40,29 @@ export function useMapFly(options = {}) {
|
|||
defaultZoom = mapDefaults?.zoom || 2
|
||||
} = options;
|
||||
|
||||
// ★ REDUCED MOTION IS DECIDED HERE, NOT BY EACH CALLER (2026-09-26). A reader who asks the OS for
|
||||
// reduced motion (prefers-reduced-motion: reduce) gets every camera move in this composable as an
|
||||
// instant jump. Callers used to pass { duration: 0 } themselves, and flyToCoords took no options, so
|
||||
// theirs was silently dropped and the map still flew: a photosensitivity issue, not a style choice.
|
||||
// Every animated call below goes through cam(map), so no caller can miss it.
|
||||
const prefersReducedMotion = () => {
|
||||
try { return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||||
catch { return false; }
|
||||
};
|
||||
function cam(map) {
|
||||
const still = prefersReducedMotion();
|
||||
const jump = (o = {}) => {
|
||||
const view = {};
|
||||
for (const k of ['center', 'zoom', 'bearing', 'pitch', 'padding']) if (o[k] !== undefined) view[k] = o[k];
|
||||
return map.jumpTo(view);
|
||||
};
|
||||
return {
|
||||
flyTo: (o) => (still ? jump(o) : map.flyTo(o)),
|
||||
easeTo: (o) => (still ? jump(o) : map.easeTo(o)),
|
||||
fitBounds: (b, o = {}) => map.fitBounds(b, still ? { ...o, animate: false, duration: 0 } : o),
|
||||
};
|
||||
}
|
||||
|
||||
// Default fly animation configuration
|
||||
const FLY_CONFIG = {
|
||||
speed: 0.5, // Slow fly speed
|
||||
|
|
@ -151,7 +174,7 @@ export function useMapFly(options = {}) {
|
|||
const offsetCenter = getOffsetCenter(center, zoom);
|
||||
|
||||
|
||||
map.flyTo({
|
||||
cam(map).flyTo({
|
||||
center: offsetCenter,
|
||||
zoom: zoom,
|
||||
...FLY_CONFIG
|
||||
|
|
@ -197,7 +220,7 @@ export function useMapFly(options = {}) {
|
|||
const offsetCenter = getOffsetCenter(center, zoom);
|
||||
|
||||
|
||||
map.flyTo({
|
||||
cam(map).flyTo({
|
||||
center: offsetCenter,
|
||||
zoom: zoom,
|
||||
...FLY_CONFIG
|
||||
|
|
@ -252,7 +275,7 @@ export function useMapFly(options = {}) {
|
|||
...flyOptions
|
||||
};
|
||||
|
||||
map.flyTo(options);
|
||||
cam(map).flyTo(options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -260,11 +283,12 @@ export function useMapFly(options = {}) {
|
|||
* @param {Object} coords - { longitude, latitude }
|
||||
* @param {number} [zoom] - Target zoom (defaults to current zoom + 2)
|
||||
*/
|
||||
function flyToCoords(coords, zoom) {
|
||||
function flyToCoords(coords, zoom, flyOptions = {}) {
|
||||
const map = getMap();
|
||||
if (!map || coords?.longitude == null || coords?.latitude == null) return;
|
||||
const targetZoom = zoom ?? Math.min(map.getZoom() + 2, 8);
|
||||
flyTo([coords.longitude, coords.latitude], { zoom: targetZoom });
|
||||
// Caller options (e.g. { duration: 0 }) are honoured; they used to be dropped (no third argument).
|
||||
flyTo([coords.longitude, coords.latitude], { ...flyOptions, zoom: targetZoom });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -295,7 +319,7 @@ export function useMapFly(options = {}) {
|
|||
// for prefers-reduced-motion users. Previously only `maxZoom` was read and every
|
||||
// other caller option was silently dropped, so a reduced-motion caller still got
|
||||
// FLY_CONFIG's easing. Passing no options is byte-for-byte the previous behaviour.
|
||||
map.fitBounds(bounds, { padding, maxZoom, duration, ...FLY_CONFIG, ...options });
|
||||
cam(map).fitBounds(bounds, { padding, maxZoom, duration, ...FLY_CONFIG, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -305,7 +329,7 @@ export function useMapFly(options = {}) {
|
|||
const map = getMap();
|
||||
if (!map) return;
|
||||
|
||||
map.flyTo({
|
||||
cam(map).flyTo({
|
||||
center: defaultCenter,
|
||||
zoom: defaultZoom,
|
||||
...FLY_CONFIG
|
||||
|
|
@ -424,7 +448,7 @@ export function useMapFly(options = {}) {
|
|||
// longitudes; zoom = one-world-fills-width; easeTo (NOT flyTo) so the camera
|
||||
// never arcs out past the floor and flashes the copies.
|
||||
function _easeWorldOverview(map, centerLng, minLat, maxLat) {
|
||||
map.easeTo({
|
||||
cam(map).easeTo({
|
||||
center: [centerLng, (minLat + maxLat) / 2],
|
||||
zoom: _noCopyZoom(map),
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
|
|
@ -622,7 +646,7 @@ export function useMapFly(options = {}) {
|
|||
if (_tooWideToFit(map, pBounds, ext.span)) {
|
||||
_easeWorldOverview(map, ext.center, pMinLat, pMaxLat);
|
||||
} else {
|
||||
map.fitBounds(pBounds, {
|
||||
cam(map).fitBounds(pBounds, {
|
||||
padding: LEGEND_FIT_PADDING_PX,
|
||||
maxZoom: LEGEND_FIT_MAX_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
|
|
@ -670,7 +694,7 @@ export function useMapFly(options = {}) {
|
|||
if (pointCount === 0) return;
|
||||
|
||||
if (pointCount === 1) {
|
||||
map.flyTo({
|
||||
cam(map).flyTo({
|
||||
center: [firstLng, firstLat],
|
||||
zoom: LEGEND_SINGLE_PIN_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
|
|
@ -693,7 +717,7 @@ export function useMapFly(options = {}) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
cam(map).fitBounds(bounds, {
|
||||
padding: LEGEND_FIT_PADDING_PX,
|
||||
maxZoom: LEGEND_FIT_MAX_ZOOM,
|
||||
duration: LEGEND_FIT_DURATION_MS,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
|
||||
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs",
|
||||
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
|
||||
"check:map-fly-reduced-motion": "node internal/check-map-fly-reduced-motion.mjs",
|
||||
"check:map-paints": "node internal/check-gospel-map-paints.mjs",
|
||||
"check:map-paints:poison": "node internal/check-gospel-map-paints.mjs --poison",
|
||||
"check:map-props-unit": "node internal/check-map-props-unit.mjs",
|
||||
|
|
@ -188,7 +189,7 @@
|
|||
"dev": "bun x vite",
|
||||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
"packet-fixture": "node internal/generate-packet-fixture.mjs",
|
||||
|
|
@ -294,6 +295,7 @@
|
|||
"check:linked-vocabulary": "the two halves of research-map-plus-grid must mean the SAME THING by a religion family. The map filters by the legend's FIRST-LETTER key ('B'), the grid by a DERIVED family name ('Buddhism'), and both read ONE shared table (library/colors/religion.js) in opposite directions - so a wrong link stays invisible: both halves look filtered and show different peoples. Compares the total reached over the composite's wire against the total the grid gives for the same facet on its own, and runs the MUTANT first (the legend key instead of the name), failing as NO-TEETH if the wrong mapping scores the same as the right one.",
|
||||
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
|
||||
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C.",
|
||||
"check:map-fly-reduced-motion": "every camera move in useMapFly is an instant jump when the reader asks for reduced motion (prefers-reduced-motion), and flyToCoords honours caller options. Headless: the real composable against a recording map.",
|
||||
"check:map-paints": "The map must put PIXELS on screen, not just features. Exists because injecting mapbox-gl.css collapsed this profile's canvas from 1440x900 to 1440x300 - a white rectangle - and EVERY assertion went green over it: verify-gospel-map counted 2,107 of 2,107 pins rendered, leaves-mount found a full shadow root, the i18n gates found every label translated and nothing clipped. queryRenderedFeatures does not care whether anything is visible. This screenshots the element and counts distinct colours (a basemap has hundreds, a blank rectangle one or two) and checks the canvas fills its container. --poison hides the canvas and requires a red verdict.",
|
||||
"check:map-props-unit": "the engine and token props decided in the library, measured in Node with no browser: auto picks the engine from the token ladder, an explicit provider wins, Clear clears the page-wide token, and tokenUrl is fetched and honoured (26 cases, one per defect U1/U3/U7).",
|
||||
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue