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
8 changes: 8 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ main {
height: 100%;
}

/* A long-press on the map drops a pin; keep iOS from also treating it as a
press on text or an image (selection, magnifier, callout). */
.maplibregl-canvas-container {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}

/* Shared MapLibre popup styling */
@layer base {
.omx-popup.maplibregl-popup .maplibregl-popup-content {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { InstallPromptCapture } from "@/components/pwa/useInstallPrompt";
import { EnvProvider } from "@/integration-api/runtime/EnvProvider";
import { AttributionLabelsSync } from "@/lib/attributionLabels";
import { loadClientEnv } from "@/lib/serverClientEnv";
import { THEME_COLOR } from "@/lib/themeColor";
import { Providers } from "./providers";

export const metadata: Metadata = {
Expand Down Expand Up @@ -49,8 +50,8 @@ export const viewport: Viewport = {
maximumScale: 5,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#207E23" },
{ media: "(prefers-color-scheme: dark)", color: "#1c1c1c" },
{ media: "(prefers-color-scheme: light)", color: THEME_COLOR.light },
{ media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark },
],
};

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { ImpersonationBanner } from "../components/admin/ImpersonationBanner";
import { SavedPlacesMirror } from "../components/pwa/SavedPlacesMirror";
import { ThemeColorSync } from "../components/pwa/ThemeColorSync";
import { createAppQueryClient } from "../lib/appQueryClient";
import { MobileRuntimeProvider } from "../lib/mobile/MobileRuntimeProvider";
import {
Expand Down Expand Up @@ -137,6 +138,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
const inner = (
<ThemeProvider theme={theme}>
<CssBaseline />
<ThemeColorSync />
{/*
These teardown-only guards must observe the raw session transition even
while the private tree is gated, so old in-memory keys and timeline
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/components/map/DeepLinkManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
writeNavigation,
} from "@/lib/backNavigation";
import { frameBoundsInstant, jumpToView } from "@/lib/cameraFraming";
import { closePlaceSelection, closeSidebarPanel } from "@/lib/closeSurfaces";
import {
DEEPLINK_UPDATE_EVENT,
formatBboxParam,
Expand Down Expand Up @@ -593,17 +594,13 @@ function applyPanels(parsed: ParsedDeepLink, map: maplibregl.Map | null): void {
*/
function closeSegment(segment: string): void {
const kind = segment.split(":", 1)[0];
const sidebar = useSidebarStore.getState();
switch (kind) {
case "tool":
if (segment === "tool:measure") useMeasurementStore.getState().deactivate();
else useTravelTimeStore.getState().deactivate();
return;
case "place":
usePlaceStore.getState().setSelectedPlace(null);
useDataSourceStore.getState().clearSelection();
if (sidebar.activeDetailId === PANEL.PLACE_CARD) sidebar.closeDetail();
else if (sidebar.activeSidebarId === PANEL.PLACE) sidebar.closeSidebar();
closePlaceSelection();
return;
case "item":
useDataSourceStore.getState().clearSelection();
Expand All @@ -612,8 +609,7 @@ function closeSegment(segment: string): void {
useSavedPlacesStore.getState().clearSelectedList();
return;
default:
sidebar.closeSidebar();
useSearchStore.getState().setQuery("");
closeSidebarPanel();
}
}

Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/components/map/MapClickHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// @vitest-environment jsdom

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeMap, render } from "@/test";

vi.mock("@/integration-api/map/MapContext", () => {
const mapContext = { mapRef: { current: null as unknown }, mapReady: true, styleVersion: 0 };
return { __test: mapContext, useMap: () => mapContext };
});

import {
createPlace,
PANEL,
useDirectionsStore,
useMapClickStore,
useNavigationStore,
useParkingStore,
usePlaceStore,
useSidebarStore,
} from "@openmapx/core";
import * as mapContextModule from "@/integration-api/map/MapContext";
import { consumeLongPressClick, LONG_PRESS_MS } from "@/lib/mapTouch";
import { MapClickHandler } from "./MapClickHandler";

const mapContextTest = (mapContextModule as unknown as { __test: { mapRef: { current: unknown } } })
.__test;

let fake: ReturnType<typeof createFakeMap>;

const PLACE = createPlace({
primaryScheme: "coordinate",
ids: { coordinate: "50.000000-6.000000" },
name: "Somewhere",
address: "Somewhere",
coordinates: [6, 50],
});

function click(pointerType: "mouse" | "touch"): void {
fake.emit("click", {
lngLat: { lng: 6.1, lat: 50.1 },
point: { x: 10, y: 10 },
originalEvent: { pointerType },
});
}

function touch(type: string, points: Array<[number, number]>): void {
const event = new Event(type, { bubbles: true });
Object.defineProperty(event, "touches", {
value: points.map(([clientX, clientY]) => ({ clientX, clientY })),
});
fake.state.canvas.dispatchEvent(event);
}

function openPlace(): void {
usePlaceStore.getState().setSelectedPlace(PLACE);
useSidebarStore.getState().openSidebar(PANEL.PLACE);
}

beforeEach(() => {
fake = createFakeMap();
fake.map.unproject = (() => ({ lng: 7, lat: 51 })) as never;
mapContextTest.mapRef.current = fake.map;
useMapClickStore.getState().setClickedLngLat(null);
usePlaceStore.getState().setSelectedPlace(null);
useSidebarStore.setState({ activeSidebarId: null, activeDetailId: null, collapsed: false });
useDirectionsStore.getState().close();
useNavigationStore.setState({ status: "idle" });
useParkingStore.setState({ picking: false });
consumeLongPressClick();
});

afterEach(() => {
vi.useRealTimers();
});

describe("MapClickHandler with a mouse", () => {
it("drops the gray pin on a click, as before", () => {
render(<MapClickHandler />);
openPlace();
click("mouse");
expect(useMapClickStore.getState().clickedLngLat).toEqual([6.1, 50.1]);
expect(usePlaceStore.getState().selectedPlace).toBeNull();
});
});

describe("MapClickHandler on a touch screen", () => {
it("only puts away the open place on a tap, without dropping a pin", () => {
render(<MapClickHandler />);
openPlace();
click("touch");
expect(usePlaceStore.getState().selectedPlace).toBeNull();
expect(useSidebarStore.getState().activeSidebarId).toBeNull();
expect(useMapClickStore.getState().clickedLngLat).toBeNull();
});

it("takes a dropped pin away on a tap before anything else", () => {
render(<MapClickHandler />);
useMapClickStore.getState().setClickedLngLat([6, 50]);
click("touch");
expect(useMapClickStore.getState().clickedLngLat).toBeNull();
});

it("drops the pin on a long-press, closing the open place", () => {
vi.useFakeTimers();
render(<MapClickHandler />);
openPlace();
touch("touchstart", [[20, 30]]);
vi.advanceTimersByTime(LONG_PRESS_MS);
expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);
expect(usePlaceStore.getState().selectedPlace).toBeNull();

// The click some browsers fire as the finger lifts leaves the pin alone.
click("touch");
expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]);
});

it("leaves a long-press to the parking picker while it is choosing a spot", () => {
vi.useFakeTimers();
useParkingStore.setState({ picking: true });
render(<MapClickHandler />);
touch("touchstart", [[20, 30]]);
vi.advanceTimersByTime(LONG_PRESS_MS);
expect(useMapClickStore.getState().clickedLngLat).toBeNull();
});

it("drops no pin during guidance", () => {
vi.useFakeTimers();
useNavigationStore.setState({ status: "navigating" });
render(<MapClickHandler />);
touch("touchstart", [[20, 30]]);
vi.advanceTimersByTime(LONG_PRESS_MS);
expect(useMapClickStore.getState().clickedLngLat).toBeNull();
});
});
51 changes: 44 additions & 7 deletions apps/web/src/components/map/MapClickHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,42 @@ import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { claimMapClick, getMapClickOwner } from "@/integration-api/map/mapClickOwnership";
import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation";
import { closePlaceSelection } from "@/lib/closeSurfaces";
import { haptics } from "@/lib/haptics";
import { initializeMapClickModes } from "@/lib/mapClickModes";
import { bindMapLongPress, consumeLongPressClick, isTouchClick } 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.
initializeMapClickModes();

/** Drops the gray pin, replacing whatever place was open. */
function dropPinAt(coordinates: [number, number]): void {
usePlaceStore.getState().setSelectedPlace(null);
if (useSidebarStore.getState().activeSidebarId === PANEL.PLACE) {
useSidebarStore.getState().closeSidebar();
}
useSidebarStore.getState().closeDetail();
useMapClickStore.getState().setClickedLngLat(coordinates);
}

/**
* A tap on empty map on a touch screen only puts away what is open — the
* dropped pin's card, then the place — as in native map apps, where people tap
* the map to clear it and long-press to drop a pin.
*/
function dismissOnTap(): void {
if (useMapClickStore.getState().clickedLngLat) {
useMapClickStore.getState().setClickedLngLat(null);
return;
}
if (usePlaceStore.getState().selectedPlace) closePlaceSelection();
}

export function MapClickHandler() {
const { mapRef, mapReady, styleVersion } = useMap();
const { clickedLngLat, setClickedLngLat } = useMapClickStore();
const { selectedPlace, setSelectedPlace } = usePlaceStore();
const { selectedPlace } = usePlaceStore();
const markerRef = useRef<maplibregl.Marker | null>(null);

// Register plain-map click handler
Expand All @@ -37,6 +63,8 @@ export function MapClickHandler() {
if (!map || !mapReady) return;

const onClick = (e: MapMouseEvent) => {
// The click some browsers fire as a long-press lifts: the pin is down.
if (consumeLongPressClick()) return;
const owner = getMapClickOwner(e);
// Repositioning a parked pin consumes the tap: falling through would also
// deselect the place and drop a grey pin under the one being moved.
Expand Down Expand Up @@ -78,19 +106,28 @@ export function MapClickHandler() {
}
}

setSelectedPlace(null);
if (useSidebarStore.getState().activeSidebarId === PANEL.PLACE) {
useSidebarStore.getState().closeSidebar();
if (isTouchClick(e.originalEvent)) {
dismissOnTap();
return;
}
useSidebarStore.getState().closeDetail();
setClickedLngLat([e.lngLat.lng, e.lngLat.lat]);
dropPinAt([e.lngLat.lng, e.lngLat.lat]);
};

// Touch screens drop the pin on a long-press instead. Pickers and tools
// own the map's taps while active, and guidance has no use for a pin.
const unbindLongPress = bindMapLongPress(map, (coordinates, event) => {
if (getMapClickOwner(event)) return;
if (useNavigationStore.getState().status !== "idle") return;
haptics.tap();
dropPinAt(coordinates);
});

map.on("click", onClick);
return () => {
map.off("click", onClick);
unbindLongPress();
};
}, [mapRef, mapReady, styleVersion, setClickedLngLat, setSelectedPlace]);
}, [mapRef, mapReady, styleVersion]);

// Clear clicked point when a place is selected externally (search, category click, etc.)
useEffect(() => {
Expand Down
31 changes: 4 additions & 27 deletions apps/web/src/components/map/MapContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ import {
coordinateId,
createPlace,
isLiveNavigationStatus,
PANEL,
type Place,
useDirectionsStore,
useMapClickStore,
useNavigationStore,
usePlaceStore,
useReverseGeocoding,
useSidebarStore,
} from "@openmapx/core";
import type * as maplibregl from "maplibre-gl";
import { useLocale, useTranslations } from "next-intl";
Expand All @@ -47,7 +43,7 @@ import Menu from "@/components/ui/Menu";
import Popover from "@/components/ui/Popover";
import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers";
import { useMap } from "@/integration-api/map/MapContext";
import { buildLocationShareUrl, shareUrl } from "@/lib/deepLink";
import { openLocationDetails, routeFromOrTo, shareLocationLink } from "@/lib/locationActions";
import { findStylePoiAtPoint, getStylePoiLayerIds, type StylePoiTarget } from "./mapStylePoiTarget";

interface MapContextTarget {
Expand Down Expand Up @@ -294,25 +290,13 @@ export function MapContextMenu(): React.ReactNode {

const chooseRouteEndpoint = (endpoint: "origin" | "destination") => {
if (!target) return;
const directions = useDirectionsStore.getState();
const index = endpoint === "origin" ? 0 : directions.waypoints.length - 1;
directions.setWaypoint(index, target.coordinates, actionLabel);
directions.open();
useSidebarStore.getState().closeDetail();
useSidebarStore.getState().openSidebar(PANEL.DIRECTIONS);
routeFromOrTo(endpoint, target.coordinates, actionLabel);
closeMenu();
};

const openPlaceDetails = () => {
if (!place) return;
usePlaceStore.getState().setSelectedPlace(place);
const sidebar = useSidebarStore.getState();
if (!sidebar.activeSidebarId || sidebar.activeSidebarId === PANEL.PLACE) {
sidebar.closeDetail();
sidebar.openSidebar(PANEL.PLACE);
} else {
sidebar.openDetail(PANEL.PLACE_CARD);
}
openLocationDetails(place);
closeMenu();
};

Expand Down Expand Up @@ -369,14 +353,7 @@ export function MapContextMenu(): React.ReactNode {
if (!place) return;
const targetGeneration = targetGenerationRef.current;
const actionGeneration = ++actionGenerationRef.current;
const url = buildLocationShareUrl(window.location.href, {
id: place.id,
coordinates: place.coordinates,
name: place.name,
category: place.category,
rawCategory: place.rawCategory,
});
const result = await shareUrl({ url, title: place.name });
const result = await shareLocationLink(place);
if (
!mountedRef.current ||
targetGeneration !== targetGenerationRef.current ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class FakeMap {
this.featuresByLayer[id] || this.styleLayers.some((layer) => layer.id === id)
? { id }
: undefined;
getCanvas = () => this.canvas;
getCanvasContainer = () => this.canvas;
getContainer = () => this.canvas;
project = () => ({ x: 400, y: 200 });
Expand Down
Loading
Loading