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
17 changes: 17 additions & 0 deletions apps/web/src/components/map/MapClickHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ beforeEach(() => {
});

afterEach(() => {
touch("touchend", []);
vi.useRealTimers();
});

Expand Down Expand Up @@ -114,6 +115,22 @@ describe("MapClickHandler on a touch screen", () => {
expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);
});

it("keeps the pin when the finger lifts long after it dropped", () => {
vi.useFakeTimers();
render(<MapClickHandler />);
touch("touchstart", [[20, 30]]);
vi.advanceTimersByTime(LONG_PRESS_MS + 2500);
touch("touchend", []);
click("touch");
expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);

// The next tap is a tap of its own, and clears the pin.
touch("touchstart", [[20, 30]]);
touch("touchend", []);
click("touch");
expect(useMapClickStore.getState().clickedLngLat).toBeNull();
});

it("leaves a long-press to the parking picker while it is choosing a spot", () => {
vi.useFakeTimers();
useParkingStore.setState({ picking: true });
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/map/MapClickHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivati
import { closePlaceSelection } from "@/lib/closeSurfaces";
import { haptics } from "@/lib/haptics";
import { initializeMapClickModes } from "@/lib/mapClickModes";
import { bindMapLongPress, consumeLongPressClick, isTouchClick } from "@/lib/mapTouch";
import { bindMapLongPress, cameFromTouch, consumeLongPressClick } from "@/lib/mapTouch";

// Configure before any map listener mounts. Read live mode state at the first
// callback for each click; capture may clear that state before later callbacks.
Expand Down Expand Up @@ -106,7 +106,7 @@ export function MapClickHandler() {
}
}

if (isTouchClick(e.originalEvent)) {
if (cameFromTouch(e.originalEvent)) {
dismissOnTap();
return;
}
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/map/MapContextMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,37 @@ describe("MapContextMenu opening", () => {
});
}

it("leaves MapLibre's own long-press contextmenu to the dropped pin", () => {
render(<MapContextMenu />);
act(() => useMapClickStore.setState({ clickedLngLat: [1, 2] }));
const fingers = (type: string, count: number) => {
const event = new Event(type, { bubbles: true });
Object.defineProperty(event, "touches", {
value: Array.from({ length: count }, () => ({ clientX: 120, clientY: 140 })),
});
document.body.dispatchEvent(event);
};
fingers("touchstart", 1);
// What MapLibre fires once a finger has rested on the map: a plain
// MouseEvent, with no pointer type or source capabilities to tell touch.
act(() => {
fake.emit("contextmenu", {
point: { x: 40, y: 50 },
lngLat: { lng: 6.08, lat: 50.77 },
originalEvent: new MouseEvent("contextmenu", {
clientX: 120,
clientY: 140,
button: 2,
cancelable: true,
}),
});
});
fingers("touchend", 0);

expect(screen.queryByRole("menu")).toBeNull();
expect(useMapClickStore.getState().clickedLngLat).toEqual([1, 2]);
});

for (const status of ["navigating", "rerouting"] as const) {
it(`suppresses the native menu without opening or mutating state while ${status}`, () => {
useNavigationStore.setState({ status });
Expand Down
88 changes: 32 additions & 56 deletions apps/web/src/components/map/MapContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import {
Typography,
} from "@mui/material";
import {
coordinateId,
createPlace,
isLiveNavigationStatus,
type Place,
useMapClickStore,
Expand All @@ -44,28 +42,22 @@ import Popover from "@/components/ui/Popover";
import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { openLocationDetails, routeFromOrTo, shareLocationLink } from "@/lib/locationActions";
import { findStylePoiAtPoint, getStylePoiLayerIds, type StylePoiTarget } from "./mapStylePoiTarget";
import { cameFromTouch } from "@/lib/mapTouch";
import {
describeMapLocation,
type MapLocation,
mapLocationCopyRows,
mapLocationPlace,
} from "./mapLocation";
import { findStylePoiAtPoint, getStylePoiLayerIds } from "./mapStylePoiTarget";

interface MapContextTarget {
coordinates: [number, number];
interface MapContextTarget extends MapLocation {
anchorPosition: { top: number; left: number };
poi: StylePoiTarget | null;
}

type ActionId = "from" | "to" | "copy" | "details" | "parking" | "share";
const ACTION_ORDER: ActionId[] = ["from", "to", "copy", "details", "parking", "share"];

function formatContextCoordinates([lng, lat]: [number, number]): string {
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}

function cameFromTouch(event: MouseEvent & { pointerType?: string }): boolean {
const capabilities = (
event as MouseEvent & { sourceCapabilities?: { firesTouchEvents?: boolean } }
).sourceCapabilities;
return event.pointerType === "touch" || capabilities?.firesTouchEvents === true;
}

export function MapContextMenu(): React.ReactNode {
const t = useTranslations("mapContextMenu");
const tParking = useTranslations("parking");
Expand Down Expand Up @@ -104,32 +96,15 @@ export function MapContextMenu(): React.ReactNode {

const coordinates = target?.coordinates ?? null;
const { data: reverseGeo } = useReverseGeocoding(coordinates, locale);
const coordinateLabel = target ? formatContextCoordinates(target.coordinates) : "";
const title = target?.poi?.name ?? reverseGeo?.city ?? reverseGeo?.address ?? coordinateLabel;
const address = reverseGeo?.address ?? coordinateLabel;
const actionLabel = target?.poi?.name ?? reverseGeo?.address ?? coordinateLabel;
const text = target ? describeMapLocation(target, reverseGeo) : null;
const title = text?.title ?? "";
const address = text?.address ?? "";
const actionLabel = text?.routeLabel ?? "";

const place = useMemo<Place | null>(() => {
if (!target) return null;
if (target.poi) {
return createPlace({
primaryScheme: "stylePoi",
ids: { stylePoi: target.poi.featureId },
name: target.poi.name,
address: reverseGeo?.address ?? target.poi.name,
coordinates: target.poi.coordinates,
category: target.poi.category,
rawCategory: target.poi.rawCategory,
});
}
return createPlace({
primaryScheme: "coordinate",
ids: { coordinate: coordinateId(target.coordinates) },
name: title,
address,
coordinates: target.coordinates,
});
}, [address, reverseGeo?.address, target, title]);
const place = useMemo<Place | null>(
() => (target ? mapLocationPlace(target, reverseGeo) : null),
[reverseGeo, target],
);

const restoreCanvasFocus = useCallback(() => {
if (restoreFocusFrameRef.current !== null) cancelAnimationFrame(restoreFocusFrameRef.current);
Expand Down Expand Up @@ -200,6 +175,10 @@ export function MapContextMenu(): React.ReactNode {
originalEvent.preventDefault();
return;
}
// Touch never opens this menu: a long-press drops the pin, whose card
// carries these actions (MapClickHandler). That includes the contextmenu
// MapLibre makes up for a held finger, which would otherwise open the
// menu under it only for the click-away listener to close it on the lift.
if (cameFromTouch(originalEvent)) return;
originalEvent.preventDefault();
captureTarget([event.lngLat.lng, event.lngLat.lat], event.point, {
Expand Down Expand Up @@ -307,20 +286,17 @@ export function MapContextMenu(): React.ReactNode {
setFeedback(outcome === "saved" ? tParking("savedToast") : tParking("locationUnavailable"));
};

const copyRows = useMemo(() => {
if (!target) return [];
const candidates = [
{ label: t("coordinates"), value: coordinateLabel },
...(target.poi?.name ? [{ label: t("name"), value: target.poi.name }] : []),
...(reverseGeo?.address ? [{ label: t("address"), value: reverseGeo.address }] : []),
];
const values = new Set<string>();
return candidates.filter(({ value }) => {
if (values.has(value)) return false;
values.add(value);
return true;
});
}, [coordinateLabel, reverseGeo?.address, t, target]);
const copyRows = useMemo(
() =>
target
? mapLocationCopyRows(target, reverseGeo, {
coordinates: t("coordinates"),
name: t("name"),
address: t("address"),
})
: [],
[reverseGeo, t, target],
);

const copyValue = async (value: string) => {
const targetGeneration = targetGenerationRef.current;
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/map/mapLocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { describeMapLocation, mapLocationCopyRows, mapLocationPlace } from "./mapLocation";

const POINT: [number, number] = [6.083, 50.776];
const LABELS = { coordinates: "Coordinates", name: "Name", address: "Address" };
const CAFE = {
featureId: "42",
name: "Café Kaiser",
coordinates: [6.0831, 50.7761] as [number, number],
};

describe("describeMapLocation", () => {
it("falls back from the place's name to the city, the address and the coordinates", () => {
const bare = { coordinates: POINT, poi: null };
expect(describeMapLocation(bare, null).title).toBe("50.776000, 6.083000");
expect(describeMapLocation(bare, { address: "Markt 1" }).title).toBe("Markt 1");
expect(describeMapLocation(bare, { address: "Markt 1", city: "Aachen" }).title).toBe("Aachen");
expect(describeMapLocation({ coordinates: POINT, poi: CAFE }, { city: "Aachen" }).title).toBe(
"Café Kaiser",
);
});

it("routes under the place's name, else the address", () => {
expect(
describeMapLocation({ coordinates: POINT, poi: null }, { address: "Markt 1", city: "Aachen" })
.routeLabel,
).toBe("Markt 1");
expect(
describeMapLocation({ coordinates: POINT, poi: CAFE }, { address: "Markt 1" }).routeLabel,
).toBe("Café Kaiser");
});
});

describe("mapLocationPlace", () => {
it("is the basemap place when there is one, else the point", () => {
expect(mapLocationPlace({ coordinates: POINT, poi: CAFE }, null).id).toBe("stylePoi:42");
expect(mapLocationPlace({ coordinates: POINT, poi: null }, null).coordinates).toEqual(POINT);
});
});

describe("mapLocationCopyRows", () => {
it("lists coordinates, name and address without repeating a value", () => {
const rows = mapLocationCopyRows(
{ coordinates: POINT, poi: { ...CAFE, name: "Markt 1" } },
{ address: "Markt 1" },
LABELS,
);
expect(rows).toEqual([
{ label: "Coordinates", value: "50.776000, 6.083000" },
{ label: "Name", value: "Markt 1" },
]);
});

it("offers only the coordinates for a bare point with no address yet", () => {
expect(mapLocationCopyRows({ coordinates: POINT, poi: null }, null, LABELS)).toHaveLength(1);
});
});
96 changes: 96 additions & 0 deletions apps/web/src/components/map/mapLocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { coordinateId, createPlace, type Place } from "@openmapx/core";
import type { StylePoiTarget } from "./mapStylePoiTarget";

/**
* A point the user picked on the map, by right-click or by long-press, and the
* basemap place under it if there is one. The desktop context menu and the
* dropped-pin card describe it the same way through these helpers.
*/
export interface MapLocation {
coordinates: [number, number];
poi: StylePoiTarget | null;
}

interface ReverseGeocoded {
address?: string | null;
city?: string | null;
}

export interface MapLocationText {
/** Latitude, longitude to six decimals. */
coordinates: string;
/** What the location is called: the place's name, else where it is. */
title: string;
/** Its address, else its coordinates. */
address: string;
/** The label a route to or from it carries. */
routeLabel: string;
}

export function formatMapCoordinates([lng, lat]: [number, number]): string {
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}

export function describeMapLocation(
{ coordinates, poi }: MapLocation,
reverse: ReverseGeocoded | null | undefined,
): MapLocationText {
const label = formatMapCoordinates(coordinates);
return {
coordinates: label,
title: poi?.name || reverse?.city || reverse?.address || label,
address: reverse?.address || label,
routeLabel: poi?.name || reverse?.address || label,
};
}

/** The location as a place: the basemap place under it, else the bare point. */
export function mapLocationPlace(
location: MapLocation,
reverse: ReverseGeocoded | null | undefined,
): Place {
const text = describeMapLocation(location, reverse);
const { poi } = location;
if (poi) {
return createPlace({
primaryScheme: "stylePoi",
ids: { stylePoi: poi.featureId },
name: poi.name,
address: reverse?.address || poi.name,
coordinates: poi.coordinates,
category: poi.category,
rawCategory: poi.rawCategory,
});
}
return createPlace({
primaryScheme: "coordinate",
ids: { coordinate: coordinateId(location.coordinates) },
name: text.title,
address: text.address,
coordinates: location.coordinates,
});
}

export interface MapLocationCopyRow {
label: string;
value: string;
}

/** What can be copied about the location, without repeating a value. */
export function mapLocationCopyRows(
location: MapLocation,
reverse: ReverseGeocoded | null | undefined,
labels: { coordinates: string; name: string; address: string },
): MapLocationCopyRow[] {
const candidates = [
{ label: labels.coordinates, value: formatMapCoordinates(location.coordinates) },
...(location.poi?.name ? [{ label: labels.name, value: location.poi.name }] : []),
...(reverse?.address ? [{ label: labels.address, value: reverse.address }] : []),
];
const seen = new Set<string>();
return candidates.filter(({ value }) => {
if (seen.has(value)) return false;
seen.add(value);
return true;
});
}
Loading
Loading