diff --git a/apps/mobile/src/map/RouteMap.tsx b/apps/mobile/src/map/RouteMap.tsx index 8339a864..a1f93b9a 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 }; @@ -339,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); @@ -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/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, }; } 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), }; }