From 07c579b30648f2250eba71584e0fc3c25a2d08a3 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Fri, 9 Oct 2026 18:18:45 +0200 Subject: [PATCH 1/2] fix(mobile): frame a selected route with its centre and zoom Fitting bounds, the native map adds the padding the camera already has to the one given (measured on Android, same deprecated API on iOS): the route landed elsewhere than screenPointOnFit expects, and the tooltip of the start left the screen. The camera now takes the centre and zoom of the fit, computed in the same projection, with the full padding. (cherry picked from commit bab02fcde4d5f2b0eec48e5c6ea6a7e1be3413d1, branch issue/305-route-frame-camera) Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/map/RouteMap.tsx | 22 +++++--- apps/mobile/src/map/tooltip.test.ts | 59 +++++++++++++++++++++ apps/mobile/src/map/tooltip.ts | 79 ++++++++++++++++++++++------- 3 files changed, 135 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/map/RouteMap.tsx b/apps/mobile/src/map/RouteMap.tsx index 8339a864..30535246 100644 --- a/apps/mobile/src/map/RouteMap.tsx +++ b/apps/mobile/src/map/RouteMap.tsx @@ -45,7 +45,13 @@ import { mapOverlayLayout, ornamentMargin } from './ornaments'; import { routeFramePadding } from './routeFrame'; import { SelectedRoute } from './SelectedRoute'; import { labelFont, mapStyleJson } from './style'; -import { screenPointOnFit, type Padding, type ScreenPoint, type TooltipStep } from './tooltip'; +import { + cameraOnFit, + screenPointOnFit, + type Padding, + type ScreenPoint, + type TooltipStep, +} from './tooltip'; /** The markers of the other routes fade out and back with a selection, `fade` (D-071). */ const markerFade = { duration: motion.durations.fade, delay: 0 }; @@ -351,14 +357,14 @@ export function RouteMap({ } const padding = framePadding(sheetCover); heldHome.current = null; + // A centre and a zoom rather than the bounds, which the native map fits with the padding it + // already has on top of this one (#305). + const fit = 'bounds' in frame ? cameraOnFit(frame.bounds, { ...viewport, padding }) : null; camera.current?.setCamera({ - ...('bounds' in frame ? { bounds: frame.bounds } : { centerCoordinate: frame.center }), - padding: { - paddingTop: padding.top, - paddingRight: padding.right, - paddingBottom: padding.bottom, - paddingLeft: padding.left, - }, + ...(fit ?? { + centerCoordinate: 'bounds' in frame ? bboxCenter(toBbox(frame.bounds)) : frame.center, + }), + padding: cameraPaddingOf(padding), ...cameraAnimation, }); // Where the steps land once framed: the tooltip of the start is placed from them. diff --git a/apps/mobile/src/map/tooltip.test.ts b/apps/mobile/src/map/tooltip.test.ts index b6e727ff..8a460358 100644 --- a/apps/mobile/src/map/tooltip.test.ts +++ b/apps/mobile/src/map/tooltip.test.ts @@ -2,6 +2,7 @@ import { mapTooltip, size } from '@widoo/tokens'; import { describe, expect, it } from 'vitest'; import type { Bounds } from './geo'; import { + cameraOnFit, screenPointOnFit, tooltipAnchorX, tooltipPlacement, @@ -101,6 +102,64 @@ describe('tooltipPlacement', () => { }); }); +describe('cameraOnFit', () => { + const mercatorX = (lng: number) => (lng + 180) / 360; + + it('zooms so that the tighter side of the bounds fills the room left by the padding', () => { + // A wide route: its width fills the 336 points between the side paddings. + const bounds: Bounds = { ne: [2.4, 48.86], sw: [2.3, 48.85] }; + const camera = cameraOnFit(bounds, { width: 400, height: 800, padding }); + const worldWidth = 512 * 2 ** (camera?.zoomLevel ?? 0); + expect(worldWidth * (mercatorX(2.4) - mercatorX(2.3))).toBeCloseTo(336, 6); + }); + + it('centres the camera on the middle of the bounds, in the projection of the map', () => { + const bounds: Bounds = { ne: [2.35, 48.9], sw: [2.34, 48.8] }; + const camera = cameraOnFit(bounds, { width: 400, height: 800, padding }); + const [lng, lat] = camera?.centerCoordinate ?? [0, 0]; + expect(lng).toBeCloseTo(2.345, 9); + // North of the arithmetic mean: Mercator stretches the north. + expect(lat).toBeGreaterThan(48.85); + expect(lat).toBeLessThan(48.851); + }); + + it('puts each step where screenPointOnFit expects it, the padding of the camera given', () => { + const mercatorY = (lat: number) => { + const rad = (lat * Math.PI) / 180; + return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2; + }; + const bounds: Bounds = { ne: [2.36, 48.87], sw: [2.32, 48.84] }; + const viewport = { + width: 411, + height: 914, + padding: { top: 413, right: 32, bottom: 398, left: 32 }, + }; + const camera = cameraOnFit(bounds, viewport); + const [centreLng, centreLat] = camera?.centerCoordinate ?? [0, 0]; + const worldWidth = 512 * 2 ** (camera?.zoomLevel ?? 0); + // The map puts its centre in the middle of the padded room. + const room = { x: 32 + (411 - 64) / 2, y: 413 + (914 - 413 - 398) / 2 }; + const step = { lat: 48.86, lng: 2.33 }; + const expected = screenPointOnFit(step, bounds, viewport); + expect(room.x + (mercatorX(step.lng) - mercatorX(centreLng)) * worldWidth).toBeCloseTo( + expected.x, + 6, + ); + expect(room.y + (mercatorY(step.lat) - mercatorY(centreLat)) * worldWidth).toBeCloseTo( + expected.y, + 6, + ); + }); + + it('gives no camera for a single point or a room without height', () => { + const point: Bounds = { ne: [2.34, 48.86], sw: [2.34, 48.86] }; + expect(cameraOnFit(point, { width: 400, height: 800, padding })).toBeNull(); + const bounds: Bounds = { ne: [2.4, 48.86], sw: [2.3, 48.85] }; + const closed = { ...padding, top: 400, bottom: 400 }; + expect(cameraOnFit(bounds, { width: 400, height: 800, padding: closed })).toBeNull(); + }); +}); + describe('tooltipAnchorX', () => { const layout = { screenWidth: 400, tooltipWidth: 260, margin: 16 }; diff --git a/apps/mobile/src/map/tooltip.ts b/apps/mobile/src/map/tooltip.ts index 0eff01b4..bef5ea74 100644 --- a/apps/mobile/src/map/tooltip.ts +++ b/apps/mobile/src/map/tooltip.ts @@ -1,6 +1,6 @@ import type { LatLng } from '@widoo/shared'; import { mapTooltip, size } from '@widoo/tokens'; -import type { Bounds } from './geo'; +import type { Bounds, LngLat } from './geo'; /** The step a tooltip of the map points at when « Voir plus » is tapped (Ecrans › E-04). */ export interface TooltipStep { @@ -23,6 +23,11 @@ const mercatorY = (lat: number) => { const rad = (lat * Math.PI) / 180; return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2; }; +const longitudeOf = (x: number) => x * 360 - 180; +const latitudeOf = (y: number) => (Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) * 180) / Math.PI; + +/** Width of the world at zoom 0, in points: the Mapbox tile size (`geo.ts`). */ +const worldSizeAtZoomZero = 512; /** A point on the screen, in points from the top left corner of the map. */ export interface ScreenPoint { @@ -30,32 +35,72 @@ export interface ScreenPoint { y: number; } +/** A map `width` by `height` wide, and the padding of the camera around the zone it frames. */ +export interface FitViewport { + width: number; + height: number; + padding: Padding; +} + /** - * Where a point is on the screen, in points, once the camera fits `bounds` in a map `width` by - * `height` wide with `padding`: the fit keeps the scale of the tighter side and centres the - * bounds in the room left by the padding. + * How the camera fits `bounds` in the viewport: the scale of the tighter side, in points per + * world width, infinite for a single point, and the centre of the bounds, put in the middle of + * the room left by the padding. */ -export function screenPointOnFit( - point: LatLng, - bounds: Bounds, - { width, height, padding }: { width: number; height: number; padding: Padding }, -): ScreenPoint { +function fitOf(bounds: Bounds, { width, height, padding }: FitViewport) { const [east, north] = bounds.ne; const [west, south] = bounds.sw; const roomWidth = width - padding.left - padding.right; const roomHeight = height - padding.top - padding.bottom; const spanX = mercatorX(east) - mercatorX(west); const spanY = mercatorY(south) - mercatorY(north); - const scale = Math.min( - spanX > 0 ? roomWidth / spanX : Infinity, - spanY > 0 ? roomHeight / spanY : Infinity, - ); - const centreX = (mercatorX(east) + mercatorX(west)) / 2; - const centreY = (mercatorY(south) + mercatorY(north)) / 2; + return { + scale: Math.min( + spanX > 0 ? roomWidth / spanX : Infinity, + spanY > 0 ? roomHeight / spanY : Infinity, + ), + centreX: (mercatorX(east) + mercatorX(west)) / 2, + centreY: (mercatorY(south) + mercatorY(north)) / 2, + room: { x: padding.left + roomWidth / 2, y: padding.top + roomHeight / 2 }, + }; +} + +/** + * Where a point is on the screen, in points, once the camera fits `bounds` in a map `width` by + * `height` wide with `padding` (`cameraOnFit`): the fit keeps the scale of the tighter side and + * centres the bounds in the room left by the padding. + */ +export function screenPointOnFit( + point: LatLng, + bounds: Bounds, + viewport: FitViewport, +): ScreenPoint { + const { scale, centreX, centreY, room } = fitOf(bounds, viewport); const isScaled = Number.isFinite(scale); return { - x: padding.left + roomWidth / 2 + (isScaled ? (mercatorX(point.lng) - centreX) * scale : 0), - y: padding.top + roomHeight / 2 + (isScaled ? (mercatorY(point.lat) - centreY) * scale : 0), + x: room.x + (isScaled ? (mercatorX(point.lng) - centreX) * scale : 0), + y: room.y + (isScaled ? (mercatorY(point.lat) - centreY) * scale : 0), + }; +} + +/** + * Centre and zoom of the camera that fits `bounds` in the room left by the padding, as + * `screenPointOnFit` places the points; null for a single point, which has no zoom of its own. + * The camera takes them with the padding rather than the bounds: fitting bounds, the native map + * adds the padding the camera already has to the one given, on Android at least (#305), and the + * route lands elsewhere than the tooltip and the frame expect. + */ +export function cameraOnFit( + bounds: Bounds, + viewport: FitViewport, +): { centerCoordinate: LngLat; zoomLevel: number } | null { + const { scale, centreX, centreY } = fitOf(bounds, viewport); + if (!Number.isFinite(scale) || scale <= 0) { + return null; + } + return { + centerCoordinate: [longitudeOf(centreX), latitudeOf(centreY)], + zoomLevel: Math.log2(scale / worldSizeAtZoomZero), }; } From 800e470503f5a2b4e927c0e7c19c66b422caf671 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Thu, 22 Oct 2026 13:09:49 +0200 Subject: [PATCH 2/2] fix(mobile): keep the tooltip of a selected route under the top bar The padding of the camera gave the upper half to the tooltip but capped its room to leave the route a touch target above the sheet: with a tall summary, the tooltip of the start passed under the search pill and the chips. The highest step now always sits the measured bar, a 16 point gap and the measured tooltip below the top of the map, and the route keeps whatever band is left above the sheet, the map zooming out until it fits. The sheet gives way only when no band is left at all. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/map/RouteMap.tsx | 6 +-- apps/mobile/src/map/routeFrame.test.ts | 57 +++++++++++++++++++++----- apps/mobile/src/map/routeFrame.ts | 50 +++++++++++++--------- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/map/RouteMap.tsx b/apps/mobile/src/map/RouteMap.tsx index 30535246..a1f93b9a 100644 --- a/apps/mobile/src/map/RouteMap.tsx +++ b/apps/mobile/src/map/RouteMap.tsx @@ -345,9 +345,9 @@ export function RouteMap({ `${route.id}:${sheetCover}:${framePadding(sheetCover).top}`; /** - * The route fills the lower half of the map, its tooltip the upper half under the bar at the - * top, above the room kept for the results sheet and its step dots whole; the tooltip slides - * sideways to stay on screen. + * The route and the tooltip of its start fit whole between the bar at the top and the room + * kept for the results sheet, however far the map zooms out (`routeFramePadding`); the tooltip + * slides sideways to stay on screen. */ const frameRoute = (route: RouteCard, sheetCover: number) => { const frame = selectionFrame(route); diff --git a/apps/mobile/src/map/routeFrame.test.ts b/apps/mobile/src/map/routeFrame.test.ts index 84e33645..99629aca 100644 --- a/apps/mobile/src/map/routeFrame.test.ts +++ b/apps/mobile/src/map/routeFrame.test.ts @@ -1,6 +1,12 @@ import { size, spacing } from '@widoo/tokens'; import { describe, expect, it } from 'vitest'; -import { minRouteBand, routeFramePadding, stepDotClearance, topBarGap } from './routeFrame'; +import { + leastRouteBand, + minRouteBand, + routeFramePadding, + stepDotClearance, + topBarGap, +} from './routeFrame'; // iPhone 17 Pro at 100 % of text: status bar, search pill and chips, and the start tooltip. const layout = { @@ -42,19 +48,50 @@ describe('routeFramePadding', () => { expect(routeFramePadding(larger).top - routeFramePadding(atLargeText).top).toBe(30); }); - it('leaves the route a step touch target when the sheet and the tooltip are both tall', () => { - // A tall summary in the sheet: the tooltip would leave the route less than its band. - const crowded = { ...layout, topBarHeight: 180, tooltipHeight: 242, sheetCover: 400 }; + it('keeps the gap of a tooltip to the edges of the screen under the bar', () => { + expect(topBarGap).toBe(spacing['space-16']); + }); + + it('keeps the tooltip under the bar when the sheet and the tooltip are both tall', () => { + // A tall summary in the sheet: the route keeps what the bar and the tooltip leave it. + const crowded = { ...layout, topBarHeight: 180, tooltipHeight: 242, sheetCover: 350 }; const { top, bottom } = routeFramePadding(crowded); - expect(top).toBe(874 - bottom - minRouteBand); - expect(top).toBeLessThan(180 + topBarGap + 242); - expect(top).toBeGreaterThan((874 - 83) / 2); + expect(top).toBe(180 + topBarGap + 242); + expect(bottom).toBe(350 + stepDotClearance); + // The highest step, its tooltip hanging over it, stays under the bar. + expect(top - crowded.tooltipHeight).toBeGreaterThanOrEqual(crowded.topBarHeight); + expect(874 - top - bottom).toBeGreaterThanOrEqual(minRouteBand); + }); + + it('gives the upper half up to the route before its band, the bar never', () => { + // Redmi Note 9 at the largest text: the upper half would leave the route under its band. + const note9 = { + viewportHeight: 851, + tabBarHeight: 64, + sheetCover: 413, + topBarHeight: 150, + tooltipHeight: 200, + }; + const { top, bottom } = routeFramePadding(note9); + expect(top).toBe(851 - bottom - minRouteBand); + expect(top).toBeLessThan((851 - 64) / 2); + expect(top).toBeGreaterThanOrEqual(150 + topBarGap + 200); + }); + + it('frames the route in the band left, however narrow, above the sheet', () => { + // iPhone 17 Pro at XXXL, as measured: 16 points left between the tooltip and the summary. + const full = { ...layout, tabBarHeight: 98, topBarHeight: 178, tooltipHeight: 212 }; + const { top, bottom } = routeFramePadding({ ...full, sheetCover: 435 }); + expect(top).toBe(178 + topBarGap + 212); + expect(bottom).toBe(435 + stepDotClearance); + expect(874 - top - bottom).toBe(16); }); - it('never gives the tooltip less than the upper half, however tall the sheet', () => { - // iPhone 17 Pro at XXXL: the summary of the sheet leaves less than the band already. + it('lets the sheet cover the foot of the route only when no band is left', () => { const full = { ...layout, tabBarHeight: 98, topBarHeight: 178, tooltipHeight: 242 }; - expect(routeFramePadding({ ...full, sheetCover: 435 }).top).toBe((874 - 98) / 2); + const { top, bottom } = routeFramePadding({ ...full, sheetCover: 435 }); + expect(top).toBe(178 + topBarGap + 242); + expect(874 - top - bottom).toBe(leastRouteBand); }); it('keeps the upper half before the tooltip and the bar are laid out', () => { diff --git a/apps/mobile/src/map/routeFrame.ts b/apps/mobile/src/map/routeFrame.ts index ad492b80..650d63a8 100644 --- a/apps/mobile/src/map/routeFrame.ts +++ b/apps/mobile/src/map/routeFrame.ts @@ -8,25 +8,34 @@ import type { Padding } from './tooltip'; */ export const stepDotClearance = size['step-dot-active'] / 2; -/** Gap between the bar at the top of the map and a tooltip hanging over the highest step. */ -export const topBarGap = spacing['space-8']; +/** + * Gap between the bar at the top of the map and a tooltip hanging over the highest step: the + * margin a tooltip keeps from the edges of the screen (Ecrans › E-04, #626). + */ +export const topBarGap = spacing['space-16']; -/** Least height left to the route when the tooltip pushes it down: one step touch target. */ +/** Least height left to the route between the tooltip and the sheet: one step touch target. */ export const minRouteBand = size['touch-min']; +/** Least room the camera frames the route in, when the bar, the tooltip and the sheet leave none. */ +export const leastRouteBand = 1; + /** - * Padding of the camera around a selected route: the route fills the lower half of the map, its - * tooltip the upper half, above the room kept for the results sheet (`sheetCover`, the floating - * tab bar included) plus the clearance of a step dot, so that the last step never sits half under - * the sheet. Across, the margin of the screen already holds the clearance of a dot; at the top, - * the tooltip hangs over the start and covers the upper half of its dot. + * Padding of the camera around a selected route (Ecrans › E-04, D-089): the route and the tooltip + * of its start fit whole in the map left in sight, under the bar at the top of the map (status + * bar, search pill and quick chips, `topBarHeight` as laid out) and above the room kept for the + * results sheet (`sheetCover`, the floating tab bar included). At the bottom, the clearance of a + * step dot, so that the last step never sits half under the sheet; across, the margin of the + * screen already holds it. * - * The upper half grows when the tooltip would pass under the bar at the top of the map (status - * bar, search pill and quick chips, `topBarHeight` as laid out): the highest step then sits the - * measured height of the tooltip, and a gap, below the bar, so that no tooltip of the route - * hangs under it while the map is not moved. Both heights are 0 before their layout. It grows - * only while the route keeps `minRouteBand` between the tooltip and the sheet: below, the map - * would zoom out of the route's neighbourhood (#305); it never shrinks under the upper half. + * At the top, the highest step sits the measured height of its tooltip, and a gap, below the bar: + * no tooltip of the route hangs under the bar while the map is not moved (#322, #626), however + * far the map zooms out for it (D-089). Above that, the tooltip is given the upper half of the + * map, as long as the route keeps `minRouteBand` above the sheet. Both heights are 0 before their + * layout. Between the bar, the tooltip and the sheet, the route keeps whatever band is left, the + * map zooming out until it fits, its dots overlapping if they must: the user zooms in on them. + * Only when nothing is left does the bottom give way to `leastRouteBand`, the sheet then covering + * the foot of the route rather than the bar the tooltip. */ export function routeFramePadding({ viewportHeight, @@ -42,14 +51,17 @@ export function routeFramePadding({ tooltipHeight: number; }): Padding { const side = Math.max(spacing['space-32'], stepDotClearance); - const bottom = sheetCover + stepDotClearance; - const upperHalf = (viewportHeight - tabBarHeight) / 2; const underTopBar = topBarHeight + topBarGap + tooltipHeight; - const aboveRouteBand = viewportHeight - bottom - minRouteBand; + const aboveSheet = sheetCover + stepDotClearance; + const upperHalf = (viewportHeight - tabBarHeight) / 2; + const top = Math.max( + underTopBar, + Math.min(upperHalf, viewportHeight - aboveSheet - minRouteBand), + ); return { - top: Math.max(upperHalf, Math.min(underTopBar, aboveRouteBand)), + top, right: side, - bottom, + bottom: Math.max(0, Math.min(aboveSheet, viewportHeight - top - leastRouteBand)), left: side, }; }