Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions apps/mobile/src/map/RouteMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
57 changes: 47 additions & 10 deletions apps/mobile/src/map/routeFrame.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
50 changes: 31 additions & 19 deletions apps/mobile/src/map/routeFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
}
59 changes: 59 additions & 0 deletions apps/mobile/src/map/tooltip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };

Expand Down
79 changes: 62 additions & 17 deletions apps/mobile/src/map/tooltip.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -23,39 +23,84 @@ 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 {
x: number;
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),
};
}

Expand Down
Loading