diff --git a/AGENTS.md b/AGENTS.md index f6258fa..6cec3a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,10 +24,25 @@ ios/ # iOS native (Objective-C) android/ # Android native (Kotlin) plugin/ # Expo config plugin example/bare/ # Bare React Native example app +example/expo/ # Expo example app +example/shared/ # Screens and components shared by both example apps +skills/maps-usage/ # Consumer-facing AI skill (npx skills add lugg/maps) docs/ # Documentation site (Next.js + Fumadocs), deployed to maps.lodev09.com docs/content/docs/ # MDX documentation pages ``` +### Updating the AI skill + +`skills/maps-usage/` is the consumer-facing skill installed via `npx skills add lugg/maps`. Whenever `docs/content/docs/` changes (new prop, event, method, platform limitation, or pattern), update the skill to match in the same PR: + +- `SKILL.md` - quick start, recipes, "Rules That Save Debugging Time", platform table +- `references/configuration.md` - setup snippets and props (mirror `installation.mdx`, `components/*.mdx`, `types.mdx`) +- `references/api.md` - ref methods, events, payload types +- `references/advanced-patterns.md` - patterns from `example/shared/` +- `references/troubleshooting.md` - symptom → cause → fix entries + +The skill summarizes the docs and the source, it does not copy them. Keep entries terse and code-first. When docs and `src/` disagree, follow `src/` and fix the docs. + ### Creating a Pull Request When creating a PR, use the template from `.github/PULL_REQUEST_TEMPLATE.md`: diff --git a/README.md b/README.md index 77d666e..98b5a79 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ import { MapView, Marker } from '@lugg/maps'; Google Maps needs an API key on every platform. See [Installation](https://maps.lodev09.com/installation) for Expo, iOS, Android, and web setup. +## AI Skills + +Skills give your AI coding agent working knowledge of `@lugg/maps` - setup, camera control, markers and callouts, static maps, advanced patterns, and platform limitations - so it generates correct code without you explaining the library each time. + +```sh +npx skills add lugg/maps +``` + +This installs the **Maps Usage** skill into your project. The source lives in [`skills/maps-usage`](skills/maps-usage). + ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/docs/app/(home)/page.tsx b/docs/app/(home)/page.tsx index 217101b..237c00c 100644 --- a/docs/app/(home)/page.tsx +++ b/docs/app/(home)/page.tsx @@ -2,6 +2,8 @@ import type { CSSProperties } from 'react'; import Link from 'next/link'; import { ArrowRight, + BookOpen, + Bot, Camera, Globe, Image as ImageIcon, @@ -10,6 +12,8 @@ import { MapPin, Rows3, Route, + ShieldAlert, + Sparkles, Zap, } from 'lucide-react'; import { CodeSample } from '@/components/code-sample'; @@ -184,6 +188,30 @@ const PLATFORMS = [ { name: 'Web', providers: 'Google Maps' }, ]; +const SKILL_COMMAND = 'npx skills add lugg/maps'; +const SKILL_URL = `${GITHUB_URL}/tree/main/skills/maps-usage`; + +const SKILL_TOPICS = [ + { + icon: BookOpen, + title: 'Setup and API', + description: + 'Expo plugin, bare iOS and Android, web provider. Every prop, event, and ref method with platform support.', + }, + { + icon: Sparkles, + title: 'Recipes and patterns', + description: + 'Camera control, custom markers and callouts, routes, GeoJSON, static lists, bottom sheet insets, Reanimated markers.', + }, + { + icon: ShieldAlert, + title: 'Limitations and fixes', + description: + 'What differs between Apple, Google, Android, and web, plus a symptom-to-fix troubleshooting guide.', + }, +]; + export default function HomePage() { return (
@@ -381,7 +409,73 @@ export default function HomePage() { -
+
+
+
+

+ Built for agents +

+

+ Teach your coding agent the library. +

+

+ One command installs the Maps Usage skill into + your project. Claude Code, Cursor, Codex, and other agents then + pick the right patterns, respect platform limits, and generate + correct{' '} + MapView code + without you explaining the API every time. +

+
+ +
+
+ + Read the skill source + + + + Usage guide + +
+
+
+ {SKILL_TOPICS.map((topic) => ( +
+
+ +
+
+

+ {topic.title} +

+

+ {topic.description} +

+
+
+ ))} +
+ + + Ships as a standard SKILL.md{' '} + with reference files, so any agent that reads skills can use it. + +
+
+
+
+ +
diff --git a/docs/content/docs/usage.mdx b/docs/content/docs/usage.mdx index c70c8bc..72ea1e2 100644 --- a/docs/content/docs/usage.mdx +++ b/docs/content/docs/usage.mdx @@ -110,6 +110,16 @@ On iOS, both screens include a button to switch between Apple Maps and Google Ma Read more in [Static Maps](./components/map-view.mdx#static-maps). +## AI skills + +Give your AI coding agent working knowledge of `@lugg/maps` (setup, camera control, markers, static maps, advanced patterns, and platform limitations): + +```sh +npx skills add lugg/maps +``` + +This installs the **Maps Usage** skill into your project. + ## Next steps diff --git a/docs/lib/source.ts b/docs/lib/source.ts index 9ece118..8eeccb4 100644 --- a/docs/lib/source.ts +++ b/docs/lib/source.ts @@ -1,11 +1,32 @@ -import { loader } from 'fumadocs-core/source'; +import { createElement } from 'react'; +import { loader, type LoaderPlugin } from 'fumadocs-core/source'; import { defineDocs } from 'fumadocs-mdx/macro'; const docs = defineDocs({ dir: 'content/docs', }); +// Render component pages in the page tree as `` code +const componentNamesPlugin: LoaderPlugin = { + name: 'lugg:component-names', + transformPageTree: { + file(node, filePath) { + if (!filePath?.startsWith('components/')) return node; + const file = this.storage.read(filePath); + if (file?.format !== 'page') return node; + + node.name = createElement( + 'code', + { className: 'font-mono text-[0.8125rem]' }, + `<${file.data.title} />` + ); + return node; + }, + }, +}; + export const source = loader({ baseUrl: '/', source: docs.toFumadocsSource(), + plugins: [componentNamesPlugin], }); diff --git a/skills/maps-usage/SKILL.md b/skills/maps-usage/SKILL.md new file mode 100644 index 0000000..661269a --- /dev/null +++ b/skills/maps-usage/SKILL.md @@ -0,0 +1,326 @@ +--- +name: maps-usage +description: >- + Consumer-side guide for integrating @lugg/maps (universal Apple Maps / Google Maps for + React Native) into an app. Use this skill whenever the user wants to render, configure, + control, or debug a map in a React Native or Expo app — including MapView setup and API + keys (Expo config plugin, bare iOS/Android, web MapProvider), choosing Apple vs Google, + camera control (moveCamera, fitCoordinates, setEdgeInsets, reload), markers with custom + views, callouts, drag, rotation and scale, Reanimated-driven marker animation, polylines + with gradients and snake animation, polygons, circles, ground and tile overlays, GeoJSON, + static maps in lists (staticMode / staticKey), user location, edge insets for bottom + sheets, map events, theming, POI filtering, and Jest testing. Also use when the user + describes a map, pins, routes, or geofences in a React Native context without naming + @lugg/maps, or asks about any MapView / Marker / Polyline prop, event, method, or + platform limitation. +--- + +# @lugg/maps Consumer Guide + +Use this skill to produce correct, idiomatic code for apps that consume `@lugg/maps`. It covers setup, picking the right provider, applying the public API correctly, and avoiding platform-specific pitfalls. + +Requires React Native New Architecture (Fabric) — on by default in React Native 0.76+ and Expo SDK 52+. Google Maps needs an API key on every platform; Apple Maps needs none. + +## Quick Start + +```tsx +import { MapView, Marker } from '@lugg/maps'; + +export function Map() { + return ( + + + + ); +} +``` + +`MapView` must have a size (`flex: 1`, a fixed `height`, or `StyleSheet.absoluteFill`). Children are map overlays only (`Marker`, `Polyline`, `Polygon`, `Circle`, `GeoJson`, `GroundOverlay`, `TileOverlay`) — render buttons and cards as siblings positioned over the map, not as map children. + +## Setup at a Glance + +| Target | What to do | +|--------|-----------| +| **Expo** | Add the config plugin to `app.json` with `iosGoogleMapsApiKey` / `androidGoogleMapsApiKey`, then `npx expo prebuild --clean`. Apple-only apps: `"iosGoogleMapsEnabled": false` drops the Google SDK | +| **Bare iOS** | `GMSServices.provideAPIKey("KEY")` in `AppDelegate.swift`, `pod install`. Apple-only: `$LuggMapsGoogleEnabled = false` at the top of the Podfile | +| **Bare Android** | `com.google.android.geo.API_KEY` meta-data in `AndroidManifest.xml` | +| **Web** | `yarn add @vis.gl/react-google-maps`, wrap the app in `` | + +`MapProvider` is a pass-through on native, so it's safe to wrap the whole app once. Full snippets in [Configuration](./references/configuration.md#setup). + +## Providers + +| Platform | Providers | Default | +|----------|-----------|---------| +| iOS | `'apple'`, `'google'` | `'apple'` | +| Android | `'google'` | `'google'` | +| Web | `'google'` (Maps JavaScript API) | `'google'` | + +```tsx + +``` + +`provider` is creation-time. To switch at runtime, remount: ``. With the Google SDK excluded on iOS, `provider="google"` falls back to Apple and logs a warning. + +The `'google' | 'apple'` string type is exported as `MapProviderType` (the name `MapProvider` is the web context component). + +## Camera + +Set the starting camera with `initialCoordinate` + `initialZoom` (Google-style zoom levels; Apple Maps emulates them). Everything after that is imperative through a ref: + +```tsx +import { useRef } from 'react'; +import { MapView, type MapViewRef } from '@lugg/maps'; + +const mapRef = useRef(null); + +// Move (keeps heading/pitch; omit zoom to keep current zoom) +mapRef.current?.moveCamera(coordinate, { zoom: 15, duration: 500 }); + +// Fit many points (resets heading to north) +mapRef.current?.fitCoordinates(coords, { + padding: { top: 60, left: 40, bottom: 40, right: 40 }, + duration: 500, +}); + +// Shift the visible viewport (e.g. under a bottom sheet) +mapRef.current?.setEdgeInsets({ top: 0, left: 0, bottom: 240, right: 0 }, { duration: 300 }); + +// Recover from missing tiles after a network outage +mapRef.current?.reload(); + + mapRef.current?.fitCoordinates(coords)} /> +``` + +- `duration`: milliseconds. `-1` (default) = platform default animation, `0` = instant. +- `fitCoordinates` with a single coordinate calls `moveCamera` at `initialZoom`. +- `reload()` recreates a live map at its current coordinate and zoom (heading/pitch reset, `onReady` fires again). Markers and overlays stay. +- Clamp zoom with `minZoom` / `maxZoom`. + +## Common Recipes + +### Custom marker view + +```tsx + + + Pickup + + +``` + +- `anchor` is the fraction of the view placed at the coordinate. Default `{x: 0.5, y: 1}` (bottom-center, pin-style). Use `{x: 0.5, y: 0.5}` for dots and avatars. +- Custom views are rasterized to a bitmap by default (`rasterize`). Set `rasterize={false}` only when the content itself animates (Lottie, spinners). `coordinate`, `rotate`, `scale`, `zIndex` updates work either way. +- Remote images inside markers appear when loaded — no extra handling. + +### Callouts + +```tsx +// Native bubble from title/description + + +// Custom content in the native bubble (rasterized on Google Maps → not tappable) +} /> + +// Interactive custom callout (live view, no native chrome) +} + calloutOptions={{ bubbled: false, offset: { x: 0, y: -8 } }} +/> +``` + +Rule: **buttons inside a callout need `bubbled: false`.** Bubbled callouts are rendered as images on Google Maps (iOS and Android). Show/hide programmatically with `markerRef.current?.showCallout()` / `hideCallout()`. + +### Draggable marker + +```tsx +const [coord, setCoord] = useState(initial); + + setCoord(e.nativeEvent.coordinate)} +/> +``` + +### Route with gradient and snake animation + +```tsx + + +``` + +Layer a static gray line under an animated one for a "progress" look. `strokeColors` with one entry is a solid line; more entries spread evenly as a gradient. Polylines have **no `onPress`**. + +### Shapes + +```tsx + + +``` + +### GeoJSON + +```tsx + ( + + + + )} +/> +``` + +Points → `Marker`, LineStrings → `Polyline`, Polygons → `Polygon` (holes preserved). Positions are `[longitude, latitude]`. simplestyle props (`title`, `description`, `stroke`, `stroke-width`, `fill`) map automatically; render callbacks win over them. Return **keyed** elements from render callbacks, and keep `geojson` and callbacks referentially stable (`useMemo` / `useCallback`) — the component is memoized on them. + +### Static maps in lists + +```tsx + open(place)}> + + + + + + +``` + +- `staticMode` is creation-time. It renders a lite-mode bitmap (Android), an off-thread snapshot (iOS), or a gesture-less live map (web). +- `staticKey` (iOS) caches the base image across list recycling — use the row id. Camera and map settings are part of the key automatically; markers and shapes stay live and re-render from props. +- Bound how many rows mount at once (`windowSize`, `initialNumToRender`, `maxToRenderPerBatch`) — every mounted static map holds an image. +- Taps: wrap in a `Pressable` with `pointerEvents="none"` on the map container; `onPress` is disabled in static mode. +- Compute the camera up front (see [fitted camera](./references/advanced-patterns.md#fitted-camera-for-static-maps)) instead of calling `fitCoordinates` in `onReady`. + +### User location + +```tsx + +``` + +The library never requests permission. Request it first (`expo-location`, `PermissionsAndroid`, `react-native-permissions`), then flip the prop — Android checks permission at the moment the prop is set and silently ignores it otherwise. iOS needs `NSLocationWhenInUseUsageDescription` in Info.plist. + +### Map under a bottom sheet + +Keep the map full-screen and push the *viewport* up with edge insets so the camera center, attribution, and compass move together: + +```tsx +// e.g. from TrueSheet onDetentChange / onDidPresent +const bottom = screenHeight - event.nativeEvent.position; +mapRef.current?.setEdgeInsets({ top: 0, left: 0, bottom, right: 0 }); +``` + +Camera events report the **logical** center (inset-adjusted), so a fixed center pin overlay stays accurate. See [Bottom sheet + center pin](./references/advanced-patterns.md#bottom-sheet-with-edge-insets-and-center-pin). + +### Animated marker (Reanimated) + +```tsx +const AnimatedMarker = Animated.createAnimatedComponent(Marker); + +const animatedProps = useAnimatedProps(() => ({ + coordinate: { latitude: lat.value, longitude: lng.value }, + rotate: bearing.value, + scale: scale.value, +})); + + + + +``` + +Native only — on web drive `coordinate` / `rotate` with state (`Platform.select` or a `.web.tsx` file). Full example in [Advanced Patterns](./references/advanced-patterns.md#animated-marker-along-a-route). + +## Rules That Save Debugging Time + +1. **Size the map.** `MapView` renders nothing without `flex: 1`, a fixed height, or `absoluteFill`. +2. **Only map components as children.** Overlay UI (buttons, cards, center pins) goes as siblings in an outer `View`. +3. **`provider` and `staticMode` are creation-time.** Change them by remounting with `key`. +4. **Web needs `MapProvider` + `@vis.gl/react-google-maps`.** Native ignores it, so wrap once at the root. +5. **Camera payload is `{ coordinate, zoom, gesture }`.** `gesture` is `true` for user-driven moves. Prefer `onCameraIdle` for state; `onCameraMove` fires every frame. +6. **`moveCamera` without `zoom` keeps the current zoom.** `fitCoordinates` resets heading to north and falls back to `moveCamera` for one coordinate. +7. **Interactive callouts need `calloutOptions={{ bubbled: false }}`.** Bubbled custom callouts are bitmaps on Google Maps. +8. **Don't set `rasterize={false}` by default.** Only for markers whose *content* animates. Coordinate/rotate/scale changes don't need it. +9. **Animate markers with `animatedProps`, not `setState` per frame.** Web falls back to state. +10. **`userLocationEnabled` doesn't ask for permission.** Request first, then enable. +11. **No `onPress` on `Polyline`; `TileOverlay.onPress` is not supported natively.** Put a transparent `Polygon` or markers on top if you need taps on a line. +12. **Overlapping markers: derive `zIndex` from latitude** (`Math.round((90 - lat) * 10000)`) so southern markers draw on top, like real pins. +13. **GeoJSON is `[lng, lat]`.** Render callbacks must return keyed elements. +14. **Static lists: `pointerEvents="none"` + `Pressable` wrapper, `staticKey` = row id, bounded `windowSize`.** +15. **Bottom sheets: inset the viewport (`edgeInsets` / `setEdgeInsets`), don't shrink the map.** +16. **`mapId` defaults to `DEMO_MAP_ID` on Google.** Supply your own cloud Map ID for production styling. Ignored by Android lite-mode static maps. On Apple it's a map configuration name. +17. **Apple-only props:** `poiEnabled`, `poiFilter`, `'muted-standard'`. `'terrain'` falls back to standard on Apple. **Android-only:** `userLocationButtonEnabled`. **Google-only:** `GroundOverlay.bearing`. + +## Platform Differences at a Glance + +| Feature | iOS Apple | iOS Google | Android | Web | +|---------|-----------|------------|---------|-----| +| Provider available | Yes | Yes (needs key, can be excluded) | Google only | Google only | +| `mapType: 'terrain'` | Falls back to standard | Yes | Yes | Yes | +| `mapType: 'muted-standard'` | Yes | Falls back to standard | Falls back | Falls back | +| `mapId` | Configuration name | Cloud Map ID | Cloud Map ID (not in lite static) | Cloud Map ID | +| `theme` (light/dark/system) | Yes | Yes | Yes | Yes | +| `poiEnabled` / `poiFilter` | Yes | No | No | No | +| `rotateEnabled` / `pitchEnabled` | Yes | Yes | Yes | `pitchEnabled` only | +| `compassEnabled` | Yes | Yes | Yes | Toggles rotate control | +| `userLocationButtonEnabled` | No | No | Yes | No | +| `insetAdjustment` (safe area) | Yes | Yes | Yes | No | +| `staticMode` | Snapshot (`MKMapSnapshotter`) | Warm-up map → image | Lite mode bitmap | Live map, gestures off | +| `staticKey` cache | Yes | Yes | No | No | +| Bubbled custom callout | Live view | Rasterized image | Rasterized image | InfoWindow (live) | +| Non-bubbled callout | Live view | Live view | Live view | Styled InfoWindow | +| Marker `rasterize` | Yes | Yes | Yes | N/A | +| Reanimated `animatedProps` on `Marker` | Yes | Yes | Yes | No | +| `Polyline` gradient + `animated` | Yes | Yes | Yes | Yes (JS) | +| `GroundOverlay.bearing` | No | Yes | Yes | No | +| `TileOverlay.onPress` | No | No | No | Yes | +| `onLongPress` | Native | Native | Native | Emulated (500 ms mousedown) | +| Location permission prompt | App's job | App's job | App's job (checked at set time) | Browser prompt | + +## Events + +| Event | When it fires | Payload (`e.nativeEvent`) | +|-------|--------------|---------------------------| +| `onReady` | Map loaded (again after `reload()`) | — | +| `onPress` / `onLongPress` | Map tapped / long-pressed (not in static mode) | `{ coordinate, point }` | +| `onCameraMove` | Continuously while the camera moves | `{ coordinate, zoom, gesture }` | +| `onCameraIdle` | Camera settled | `{ coordinate, zoom, gesture }` | +| `Marker.onPress` | Marker tapped (also centers the map unless `centerOnPress={false}`) | `{ coordinate, point }` | +| `Marker.onDragStart` / `onDragChange` / `onDragEnd` | Drag lifecycle (`draggable`) | `{ coordinate, point }` | +| `Polygon` / `Circle` / `GroundOverlay` `onPress` | Shape tapped | — | + +Full list and types in the [API reference](./references/api.md#events). + +## Methods + +**`MapViewRef`:** `moveCamera(coordinate, { zoom?, duration? })`, `fitCoordinates(coordinates, { padding?, duration? })`, `setEdgeInsets(insets, { duration? })`, `reload()` + +**`MarkerRef`:** `showCallout()`, `hideCallout()` + +## Deep-Dive References + +| Reference | What's inside | +|-----------|--------------| +| [Configuration](./references/configuration.md) | Setup snippets, every prop of every component with type, default, and platform support | +| [API](./references/api.md) | Ref methods, events, and payload types | +| [Advanced Patterns](./references/advanced-patterns.md) | Bottom sheet insets, center pin, animated markers, routes, marker registries, fitted static cameras, provider switching, web, Expo plugin, Jest | +| [Troubleshooting](./references/troubleshooting.md) | Symptom → cause → fix, by platform | diff --git a/skills/maps-usage/references/advanced-patterns.md b/skills/maps-usage/references/advanced-patterns.md new file mode 100644 index 0000000..a9cf26d --- /dev/null +++ b/skills/maps-usage/references/advanced-patterns.md @@ -0,0 +1,428 @@ +# Advanced Patterns + +Production patterns for `@lugg/maps`, distilled from the example app. + +## Table of Contents + +- [Bottom sheet with edge insets and center pin](#bottom-sheet-with-edge-insets-and-center-pin) +- [Marker registry (show callouts by id)](#marker-registry-show-callouts-by-id) +- [Animated marker along a route](#animated-marker-along-a-route) +- [Route polylines](#route-polylines) +- [Marker stacking order](#marker-stacking-order) +- [Add marker on long press](#add-marker-on-long-press) +- [Provider switching at runtime](#provider-switching-at-runtime) +- [Fitted camera for static maps](#fitted-camera-for-static-maps) +- [Static map list with detail hand-off](#static-map-list-with-detail-hand-off) +- [Location permission flow](#location-permission-flow) +- [GeoJSON with custom rendering](#geojson-with-custom-rendering) +- [Web](#web) +- [Apple Maps only builds](#apple-maps-only-builds) +- [Jest testing](#jest-testing) + +--- + +## Bottom sheet with edge insets and center pin + +Keep `MapView` full-screen. When a sheet covers the bottom, inset the viewport instead of resizing the map — the camera center, attribution, and compass shift together, and `fitCoordinates` respects the visible area. + +```tsx +import { useRef, useCallback } from 'react'; +import { StyleSheet, View, useWindowDimensions } from 'react-native'; +import Animated, { useAnimatedStyle, type SharedValue } from 'react-native-reanimated'; +import { MapView, type MapViewRef } from '@lugg/maps'; +import { TrueSheet, type DetentChangeEvent } from '@lodev09/react-native-true-sheet'; + +const bottomInsets = (bottom: number) => ({ top: 0, left: 0, bottom, right: 0 }); + +export function PickupScreen({ animatedPosition }: { animatedPosition: SharedValue }) { + const mapRef = useRef(null); + const { height } = useWindowDimensions(); + + // Sheet position is measured from the top; bottom inset = covered height + const syncInsets = useCallback( + (e: DetentChangeEvent) => { + mapRef.current?.setEdgeInsets(bottomInsets(height - e.nativeEvent.position)); + }, + [height] + ); + + // Fixed pin that follows the inset center + const pinStyle = useAnimatedStyle(() => { + const bottom = height - animatedPosition.value; + return { transform: [{ translateY: -bottom / 2 }] }; + }); + + return ( + + mapRef.current?.setEdgeInsets(bottomInsets(height - animatedPosition.value))} + onCameraIdle={(e) => setPickup(e.nativeEvent.coordinate)} // already inset-adjusted + /> + + + {/* sheet content */} + + + ); +} +``` + +Why this works: `onCameraIdle` reports the logical center after insets, so the coordinate under the pin is exactly `e.nativeEvent.coordinate`. No manual projection math. + +For a static-height card instead of a sheet, pass `edgeInsets={{ top: 0, left: 0, right: 0, bottom: CARD_HEIGHT }}` as a prop — it works for static maps too. + +## Marker registry (show callouts by id) + +Collect marker refs in a `Map` keyed by your data id, then expose `showCallout` through the wrapping component: + +```tsx +const markerRefs = useRef(new globalThis.Map()); + +const registerMarker = useCallback((id: string, r: MarkerRef | null) => { + if (r) markerRefs.current.set(id, r); + else markerRefs.current.delete(id); +}, []); + +{places.map((p) => ( + registerMarker(p.id, r)} coordinate={p.coordinate} title={p.name} /> +))} + +// Later +mapRef.current?.moveCamera(place.coordinate); +markerRefs.current.get(place.id)?.showCallout(); +``` + +Name the ref map `globalThis.Map` when `Map` collides with your map component. + +## Animated marker along a route + +Native: drive `coordinate`, `rotate`, `scale`, and `zIndex` from Reanimated shared values with `animatedProps`. No re-renders per frame. + +```tsx +import Animated, { Easing, useAnimatedProps, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'; +import { Marker, type Coordinate } from '@lugg/maps'; +import { getRhumbLineBearing } from 'geolib'; + +const AnimatedMarker = Animated.createAnimatedComponent(Marker); + +// Shortest-turn bearing so the icon never spins the long way round +const nextBearing = (from: Coordinate, to: Coordinate, current: number) => { + let b = getRhumbLineBearing(from, to); + while (b - current > 180) b -= 360; + while (b - current < -180) b += 360; + return b; +}; + +export function Vehicle({ route, zoom }: { route: Coordinate[]; zoom: number }) { + const lat = useSharedValue(route[0]!.latitude); + const lng = useSharedValue(route[0]!.longitude); + const bearing = useSharedValue(0); + const scale = useSharedValue(1); + const bearingRef = useRef(0); + + useEffect(() => { + scale.value = withTiming(zoomToScale(zoom), { duration: 200 }); + }, [zoom]); + + useEffect(() => { + let timer: ReturnType; + const step = (i: number) => { + if (i >= route.length - 1) return step(0); + const from = route[i]!, to = route[i + 1]!; + bearingRef.current = nextBearing(from, to, bearingRef.current); + bearing.value = withTiming(bearingRef.current, { duration: 300, easing: Easing.out(Easing.ease) }); + lat.value = withTiming(to.latitude, { duration: 2000 }); + lng.value = withTiming(to.longitude, { duration: 2000 }); + timer = setTimeout(() => step(i + 1), 2000); + }; + step(0); + return () => clearTimeout(timer); + }, [route]); + + const zIndex = useDerivedValue(() => Math.round((90 - lat.value) * 10000)); + const animatedProps = useAnimatedProps(() => ({ + coordinate: { latitude: lat.value, longitude: lng.value }, + rotate: bearing.value, + scale: scale.value, + zIndex: zIndex.value, + })); + + return ( + + + + ); +} +``` + +- Keep `rasterize` at its default; the bitmap is transformed natively. Only set `rasterize={false}` when the icon *itself* animates. +- Track zoom from `onCameraIdle` to scale the icon (bigger when zoomed in). +- Smooth raw GPS points with a Catmull-Rom spline before animating so turns look natural. + +Web: `animatedProps` isn't supported. Ship a `Vehicle.web.tsx` that interpolates with `requestAnimationFrame` and `setState` on `coordinate` / `rotate`. + +## Route polylines + +```tsx +export function Route({ coordinates }: { coordinates: Coordinate[] }) { + if (coordinates.length < 2) return null; + return ( + <> + + + + ); +} +``` + +- Show "remaining route" by slicing coordinates from the vehicle's current segment: `route.slice(segmentIndex)`. +- `trailLength: 0.2` gives a short worm instead of a growing line. +- Use `delay` to stagger multiple animated routes. + +## Marker stacking order + +Real pins overlap so the southern (lower on screen) one is in front. Derive `zIndex` from latitude: + +```tsx + +``` + +Give the selected marker an explicit higher `zIndex` to lift it above everything. + +## Add marker on long press + +```tsx + addMarker(e.nativeEvent.coordinate)} + onPress={() => setSelected(null)} // empty-map tap clears selection +/> +``` + +Marker taps don't bubble to `onPress`, so this pairing is safe. + +## Provider switching at runtime + +`provider` is applied when the native map is created. Remount to switch: + +```tsx +const [provider, setProvider] = useState('apple'); + + +``` + +Carry the camera over by storing the last `onCameraIdle` payload and feeding it back as `initialCoordinate` / `initialZoom`. Disable the toggle on Android and web (`Platform.OS !== 'ios'`). + +## Fitted camera for static maps + +Static maps can't animate, so compute the camera before mount instead of calling `fitCoordinates` in `onReady`. This mirrors the native static framing (Google: world is `256 · 2^zoom` points wide; Apple fits a square span to the short side): + +```ts +const mercatorX = (lng: number) => (lng + 180) / 360; +const mercatorY = (lat: number) => (1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2; + +export const fittedCamera = ( + coordinates: Coordinate[], + provider: MapProviderType, + size: { width: number; height: number }, + padding: number +) => { + const xs = coordinates.map((c) => mercatorX(c.longitude)); + const ys = coordinates.map((c) => mercatorY(c.latitude)); + const [minX, maxX, minY, maxY] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const centerY = (minY + maxY) / 2; + const coordinate = { + latitude: (Math.atan(Math.sinh(Math.PI * (1 - 2 * centerY))) * 180) / Math.PI, + longitude: ((minX + maxX) / 2) * 360 - 180, + }; + const scale = Math.max( + (maxX - minX) / Math.max(size.width - padding * 2, 1), + (maxY - minY) / Math.max(size.height - padding * 2, 1) + ); + const zoom = + provider === 'apple' + ? 0.5 + Math.log2(1 / (scale * Math.min(size.width, size.height) * Math.cos((coordinate.latitude * Math.PI) / 180))) + : Math.log2(1 / (256 * scale)); + return { coordinate, zoom }; +}; +``` + +Use with `initialCoordinate={camera.coordinate} initialZoom={camera.zoom}`. For a single point just pass it with a fixed zoom. + +## Static map list with detail hand-off + +```tsx +const PlaceCard = ({ place, provider, onSelect }) => { + const { width } = useWindowDimensions(); + const camera = fittedCamera(placeCoordinates(place), provider, { width: width - 32, height: 140 }, 16); + + return ( + onSelect(place)}> + + + + + + + + ); +}; + + p.id} + renderItem={({ item }) => } + windowSize={5} + initialNumToRender={4} + maxToRenderPerBatch={4} +/> +``` + +- On the detail screen, render the same children with the same `staticKey` — on iOS the cached base image is reused instantly (the camera and size are part of the key, so a different framing renders fresh). +- Keep the marker/shape content deterministic per `staticKey` (no `Math.random()` in render) or the cache never hits. +- Provide a "Reload" affordance calling `mapRef.current?.reload()` for rows that rendered offline. + +## Location permission flow + +```tsx +export const useLocationPermission = () => { + const [granted, setGranted] = useState(false); + + useEffect(() => { + (async () => { + if (Platform.OS === 'web') { + const status = await navigator.permissions.query({ name: 'geolocation' }); + setGranted(status.state !== 'denied'); + return; + } + if (Platform.OS === 'ios') { + // Add NSLocationWhenInUseUsageDescription to Info.plist and request via + // expo-location / react-native-permissions; MapKit doesn't prompt by itself. + setGranted(true); + return; + } + const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); + setGranted(result === PermissionsAndroid.RESULTS.GRANTED); + })(); + }, []); + + return granted; +}; + + +``` + +Set the prop only after the grant — Android evaluates permission when the prop is applied. + +## GeoJSON with custom rendering + +```tsx +const geojson = useMemo(() => parse(raw), [raw]); + +const renderPolygon = useCallback( + (props: PolygonProps, feature: Feature) => ( + select(feature)} + /> + ), + [select] +); + + +``` + +- `GeoJson` is `memo`'d on `geojson` and the render callbacks; unstable references rebuild every element each render. +- Return `null` from a callback to skip a feature. +- Attach `onPress` in the callback — default rendering has no press handlers. +- Large collections: pre-filter features to the visible bounds from `onCameraIdle` rather than rendering thousands of markers. + +## Web + +Web renders through `@vis.gl/react-google-maps` (optional peer dependency). One `MapProvider` at the root with the browser key: + +```tsx + + + +``` + +Differences to plan for: + +- Google only. `provider="apple"` is ignored. +- Markers are Advanced Markers, so a `mapId` is required; the library falls back to `DEMO_MAP_ID`. Supply your own for production. +- Ignored props: `rotateEnabled`, `poiEnabled`, `poiFilter`, `staticKey`, `insetAdjustment`, `userLocationButtonEnabled`, `rasterize`. +- `staticMode` keeps a live map with gestures, POI clicks, keyboard shortcuts, and press events disabled. +- `onLongPress` is emulated (500 ms mousedown without drag). +- Reanimated `animatedProps` on `Marker` isn't available — animate with state in a `.web.tsx` file. +- `Polyline` gradients and snake animation are drawn in JS with `requestAnimationFrame`. +- `GroundOverlay.bearing` is not applied. +- Restrict the browser key by HTTP referrer in Google Cloud; it's public in the bundle. + +## Apple Maps only builds + +Drop the Google SDK (smaller binary, no key needed): + +- Expo: `"iosGoogleMapsEnabled": false` in the plugin config, then `npx expo prebuild --platform ios`. +- Bare: `$LuggMapsGoogleEnabled = false` at the top of the Podfile, remove `import GoogleMaps` / `GMSServices.provideAPIKey`, `pod install`. + +Android still needs its Google key. Any `MapView` asking for `provider="google"` on iOS falls back to Apple and logs a warning. + +## Jest testing + +The package ships no Jest mocks. Native components come from Codegen, so mock the module: + +```ts +// jest.setup.ts +jest.mock('@lugg/maps', () => { + const React = require('react'); + const { View } = require('react-native'); + const stub = (name: string) => + Object.assign( + React.forwardRef((props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + moveCamera: jest.fn(), + fitCoordinates: jest.fn(), + setEdgeInsets: jest.fn(), + reload: jest.fn(), + showCallout: jest.fn(), + hideCallout: jest.fn(), + })); + return React.createElement(View, { ...props, testID: props.testID ?? name }, props.children); + }), + { displayName: name } + ); + + return { + MapView: stub('MapView'), + MapProvider: ({ children }: any) => children, + Marker: stub('Marker'), + Polyline: stub('Polyline'), + Polygon: stub('Polygon'), + Circle: stub('Circle'), + GeoJson: stub('GeoJson'), + GroundOverlay: stub('GroundOverlay'), + TileOverlay: stub('TileOverlay'), + }; +}); +``` + +Fire events in tests with `fireEvent(getByTestId('MapView'), 'cameraIdle', { nativeEvent: { coordinate, zoom: 12, gesture: false } })` — the prop name minus `on`, lower-camel-cased. diff --git a/skills/maps-usage/references/api.md b/skills/maps-usage/references/api.md new file mode 100644 index 0000000..e8a6e48 --- /dev/null +++ b/skills/maps-usage/references/api.md @@ -0,0 +1,147 @@ +# API Reference + +Ref methods, events, and payload types for `@lugg/maps`. + +## Table of Contents + +- [Methods](#methods) + - [MapViewRef](#mapviewref) + - [MarkerRef](#markerref) +- [Events](#events) + - [MapView events](#mapview-events) + - [Marker events](#marker-events) + - [Shape and overlay events](#shape-and-overlay-events) +- [Payload types](#payload-types) +- [Typing refs](#typing-refs) + +--- + +## Methods + +All methods are synchronous fire-and-forget (`void`). They no-op until the native view exists, so call them from `onReady` or a user action, not during the first render. + +### MapViewRef + +| Method | Signature | Description | +|--------|-----------|-------------| +| `moveCamera` | `(coordinate: Coordinate, options?: { zoom?: number; duration?: number }) => void` | Center on a coordinate. `zoom` omitted/`0` keeps the current zoom. Heading and pitch are preserved | +| `fitCoordinates` | `(coordinates: Coordinate[], options?: { padding?: EdgeInsets; duration?: number }) => void` | Fit all points in view. Resets heading to north. `padding` is added on top of the current `edgeInsets`. A single coordinate delegates to `moveCamera` at `initialZoom`. Empty array is a no-op | +| `setEdgeInsets` | `(edgeInsets: EdgeInsets, options?: { duration?: number }) => void` | Shift the viewport (logical center, attribution, controls). Camera events report the inset-adjusted center | +| `reload` | `() => void` | Rebuild the map at the current coordinate and zoom. Live map: heading/pitch reset, `onReady` fires again, children remain. Static map: re-renders the base map and drops the cached `staticKey` image (iOS) or recreates the lite map (Android). Preserves `mapType` and edge insets | + +`duration` (ms): `-1` (default) = platform default animation, `0` = instant, `> 0` = custom. Static maps ignore it. + +```ts +interface MoveCameraOptions { zoom?: number; duration?: number } +interface FitCoordinatesOptions { padding?: EdgeInsets; duration?: number } +interface SetEdgeInsetsOptions { duration?: number } +``` + +### MarkerRef + +| Method | Signature | Description | +|--------|-----------|-------------| +| `showCallout` | `() => void` | Open the callout. No-op without `callout`, `title`, or `description` | +| `hideCallout` | `() => void` | Close the callout | + +Only one callout is open per map; showing one closes the others. + +--- + +## Events + +All handlers receive a `NativeSyntheticEvent`; read data from `e.nativeEvent`. + +### MapView events + +| Prop | Type | Fires | +|------|------|-------| +| `onReady` | `() => void` | Map finished loading. Again after `reload()`. On web, once per map instance | +| `onPress` | `(e: MapPressEvent) => void` | Tap on empty map (not on a marker/shape). Disabled in `staticMode` | +| `onLongPress` | `(e: MapPressEvent) => void` | Long press. Web: 500 ms mousedown without drag | +| `onCameraMove` | `(e: MapCameraEvent) => void` | Every camera frame — gestures, animations, programmatic moves. Keep the handler cheap | +| `onCameraIdle` | `(e: MapCameraEvent) => void` | Camera settled. Use for state, fetching, and URL sync | + +`gesture` in the camera payload: during `onCameraMove` it means the user is currently dragging; in `onCameraIdle` it means the finished movement was user-initiated (vs `moveCamera` / `fitCoordinates`). + +### Marker events + +| Prop | Type | Fires | +|------|------|-------| +| `onPress` | `(e: MarkerPressEvent) => void` | Marker tapped. Also centers the map (unless `centerOnPress={false}`) and toggles the callout if any | +| `onDragStart` | `(e: MarkerDragEvent) => void` | Drag began (`draggable` only) | +| `onDragChange` | `(e: MarkerDragEvent) => void` | Continuous during drag | +| `onDragEnd` | `(e: MarkerDragEvent) => void` | Drag finished — persist `e.nativeEvent.coordinate` to state here | + +The marker's `coordinate` prop is not mutated by dragging; if you don't write it back on `onDragEnd`, the next re-render snaps the marker back. + +### Shape and overlay events + +| Component | Prop | Type | Notes | +|-----------|------|------|-------| +| `Polygon` | `onPress` | `() => void` | Hit-tested against the fill, holes excluded | +| `Circle` | `onPress` | `() => void` | | +| `GroundOverlay` | `onPress` | `() => void` | | +| `TileOverlay` | `onPress` | `() => void` | Web only | +| `Polyline` | — | | No press support | + +Shape press handlers have no payload. Use the map's `onPress` coordinate if you need the tap location — it does **not** fire when a shape consumed the tap. + +--- + +## Payload types + +```ts +interface Coordinate { latitude: number; longitude: number } +interface Point { x: number; y: number } // view-relative pixels + +interface PressEventPayload { + coordinate: Coordinate; + point: Point; +} + +interface CameraEventPayload { + coordinate: Coordinate; // logical center (edge insets applied) + zoom: number; + gesture: boolean; +} + +type MapPressEvent = NativeSyntheticEvent; +type MapCameraEvent = NativeSyntheticEvent; +type MarkerPressEvent = NativeSyntheticEvent; +type MarkerDragEvent = NativeSyntheticEvent; +``` + +--- + +## Typing refs + +```tsx +import { useRef } from 'react'; +import { MapView, Marker, type MapViewRef, type MarkerRef } from '@lugg/maps'; + +const mapRef = useRef(null); +const markerRef = useRef(null); + + + + +``` + +`MapView` and `Marker` are class components on native, so `useRef(null)` also works, but `MapViewRef` / `MarkerRef` are the portable types across native and web. + +Wrapping the map in your own component? Forward the methods explicitly: + +```tsx +export interface AppMapRef extends MapViewRef { + showMarkerCallout(id: string): void; +} + +useImperativeHandle(ref, () => ({ + moveCamera: (...args) => mapRef.current?.moveCamera(...args), + fitCoordinates: (...args) => mapRef.current?.fitCoordinates(...args), + setEdgeInsets: (...args) => mapRef.current?.setEdgeInsets(...args), + reload: () => mapRef.current?.reload(), + showMarkerCallout: (id) => markerRefs.current.get(id)?.showCallout(), +}), []); +``` diff --git a/skills/maps-usage/references/configuration.md b/skills/maps-usage/references/configuration.md new file mode 100644 index 0000000..63f24a6 --- /dev/null +++ b/skills/maps-usage/references/configuration.md @@ -0,0 +1,312 @@ +# Configuration Reference + +Setup snippets and every `@lugg/maps` prop with type, default, and platform support. + +**Legend:** 🍎 iOS Apple Maps · 🅶 iOS Google Maps · 🤖 Android · 🌐 Web + +## Table of Contents + +- [Setup](#setup) + - [Expo](#expo) + - [Bare React Native](#bare-react-native) + - [Web](#web) +- [MapView](#mapview) +- [Marker](#marker) +- [Polyline](#polyline) +- [Polygon](#polygon) +- [Circle](#circle) +- [GeoJson](#geojson) +- [GroundOverlay](#groundoverlay) +- [TileOverlay](#tileoverlay) +- [Shared types](#shared-types) + +--- + +## Setup + +Install: + +```sh +yarn add @lugg/maps +``` + +Apple Maps works out of the box on iOS. Google Maps requires an API key per platform (Google Cloud Console → Maps SDK for iOS, Maps SDK for Android, Maps JavaScript API). + +### Expo + +```json title="app.json" +{ + "expo": { + "plugins": [ + [ + "@lugg/maps", + { + "iosGoogleMapsApiKey": "IOS_KEY", + "androidGoogleMapsApiKey": "ANDROID_KEY" + } + ] + ] + } +} +``` + +Then `npx expo prebuild --clean`. + +| Plugin option | Type | Default | Description | +|---------------|------|---------|-------------| +| `iosGoogleMapsApiKey` | `string` | — | Injects `GMSServices.provideAPIKey` into `AppDelegate.swift` and `GMSApiKey` into Info.plist | +| `androidGoogleMapsApiKey` | `string` | — | Adds `com.google.android.geo.API_KEY` meta-data to the manifest | +| `iosGoogleMapsEnabled` | `boolean` | `true` | `false` drops the Google Maps SDK from the iOS build (Apple Maps only). `iosGoogleMapsApiKey` is then ignored. Re-run `npx expo prebuild --platform ios` after changing | + +### Bare React Native + +iOS (`AppDelegate.swift`): + +```swift +import GoogleMaps + +// in application(_:didFinishLaunchingWithOptions:) +GMSServices.provideAPIKey("IOS_KEY") +``` + +Then `cd ios && pod install`. Apple-only apps add this at the top of the `Podfile` and remove the import / `provideAPIKey` lines: + +```ruby +$LuggMapsGoogleEnabled = false +``` + +Android (`AndroidManifest.xml`): + +```xml + + + +``` + +### Web + +```sh +yarn add @vis.gl/react-google-maps +``` + +```tsx +import { MapProvider } from '@lugg/maps'; + +export function App() { + return ( + + + + ); +} +``` + +| `MapProvider` prop | Type | Platforms | Description | +|--------------------|------|-----------|-------------| +| `apiKey` | `string` | 🌐 | Google Maps JavaScript API key. Ignored on native (pass-through) | +| `children` | `ReactNode` | all | — | + +--- + +## MapView + +`MapViewProps extends ViewProps` — `style`, `testID`, etc. pass through. + +### Provider and appearance + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `provider` | `'apple' \| 'google'` | `'apple'` iOS, `'google'` elsewhere | 🍎🅶🤖🌐 | Creation-time. Android/Web are always Google. Falls back to Apple with a warning when the Google SDK is excluded on iOS | +| `mapType` | `'standard' \| 'satellite' \| 'terrain' \| 'hybrid' \| 'muted-standard'` | `'standard'` | 🍎🅶🤖🌐 | `'terrain'` → standard on Apple. `'muted-standard'` → standard on Google | +| `mapId` | `string` | `DEMO_MAP_ID` (Google) | 🍎🅶🤖🌐 | Google cloud Map ID (styling, Advanced Markers). Ignored by Android lite-mode static maps. Apple: map configuration name | +| `theme` | `'light' \| 'dark' \| 'system'` | `'system'` | 🍎🅶🤖🌐 | Color scheme override | +| `poiEnabled` | `boolean` | `true` | 🍎 | Show points of interest. `false` hides all POIs regardless of `poiFilter` | +| `poiFilter` | `PoiFilter` | — | 🍎 | `{ mode?: 'including' \| 'excluding', categories: PoiCategory[] }`. Only when `poiEnabled` | +| `compassEnabled` | `boolean` | `true` | 🍎🅶🤖🌐 | Compass. On web toggles the rotate control | + +### Camera + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `initialCoordinate` | `Coordinate` | — | 🍎🅶🤖🌐 | Starting center. Later changes are ignored — use `moveCamera` | +| `initialZoom` | `number` | `10` | 🍎🅶🤖🌐 | Starting zoom (Google-style levels). Also the zoom `fitCoordinates` uses for a single coordinate | +| `minZoom` | `number` | — | 🍎🅶🤖🌐 | Lower zoom clamp | +| `maxZoom` | `number` | — | 🍎🅶🤖🌐 | Upper zoom clamp | +| `edgeInsets` | `EdgeInsets` | — | 🍎🅶🤖🌐 | Viewport insets. Shifts the logical center, attribution, and controls. Prefer `setEdgeInsets()` for animated changes | +| `insetAdjustment` | `'never' \| 'automatic'` | `'never'` | 🍎🅶🤖 | `'automatic'` adds safe-area insets to the map padding | + +### Gestures + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `zoomEnabled` | `boolean` | `true` | 🍎🅶🤖🌐 | Pinch / double-tap zoom | +| `scrollEnabled` | `boolean` | `true` | 🍎🅶🤖🌐 | Pan. Web maps `scrollEnabled: false` to `gestureHandling: 'cooperative'` (or `'none'` when zoom is also off) | +| `rotateEnabled` | `boolean` | `true` | 🍎🅶🤖 | Two-finger rotate | +| `pitchEnabled` | `boolean` | `true` | 🍎🅶🤖🌐 | Tilt. Web: `false` forces `tilt: 0` | + +### Static mode + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `staticMode` | `boolean` | `false` | 🍎🅶🤖🌐 | Creation-time. Non-interactive map for lists. Android lite mode (bitmap ≤ ~2048px, larger views fall back to a gesture-less full map), iOS snapshot, web live map with gestures/POI clicks/keyboard/press off | +| `staticKey` | `string` | — | 🍎🅶 | Stable id for the base-image cache (e.g. row id). Camera, size, provider, and map settings are part of the key automatically. Changing it discards the current image. Only fully loaded renders are cached | + +Static-mode behavior notes: + +- Ref methods work without animation (`duration` ignored). iOS re-renders at the final camera; Android re-centers. +- Map-setting prop updates after the snapshot (e.g. `mapType`) are ignored on iOS. +- `animated` polylines render complete. Tile overlays are not supported on iOS static maps. +- Markers stay live views over the base image on iOS; marker prop updates still apply. +- A theme-aware placeholder shows while loading; set `style.backgroundColor` to override. +- iOS Google: the base map is captured once tiles finish loading. If tiles fail (offline), the live warm-up map stays and retries next time the row appears. `reload()` forces it. + +### Location + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `userLocationEnabled` | `boolean` | `false` | 🍎🅶🤖🌐 | Blue dot. Requires granted permission — the library never prompts. Android checks permission when the prop is set. Web uses `navigator.geolocation` | +| `userLocationButtonEnabled` | `boolean` | `false` | 🤖 | Native my-location button (needs `userLocationEnabled`) | + +### Events + +| Prop | Payload | Platforms | Description | +|------|---------|-----------|-------------| +| `onReady` | — | 🍎🅶🤖🌐 | Map loaded. Fires again after `reload()` and per new map instance on web | +| `onPress` | `MapPressEvent` | 🍎🅶🤖🌐 | Tap on the map (disabled in static mode) | +| `onLongPress` | `MapPressEvent` | 🍎🅶🤖🌐 | Long press. Web emulates with a 500 ms mousedown timer | +| `onCameraMove` | `MapCameraEvent` | 🍎🅶🤖🌐 | Continuous while moving. `gesture` = user is dragging | +| `onCameraIdle` | `MapCameraEvent` | 🍎🅶🤖🌐 | Camera settled. `gesture` = the move was user-initiated | + +--- + +## Marker + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `coordinate` | `Coordinate` | **required** | 🍎🅶🤖🌐 | Position. Animatable via Reanimated `animatedProps` on native | +| `title` | `string` | — | 🍎🅶🤖🌐 | Native callout title | +| `description` | `string` | — | 🍎🅶🤖🌐 | Native callout subtitle | +| `anchor` | `Point` | `{x: 0.5, y: 1}` | 🍎🅶🤖🌐 | Fraction of the custom view placed at the coordinate. `{0.5, 1}` bottom-center (pin), `{0.5, 0.5}` center | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | Higher renders on top. Animatable | +| `rotate` | `number` | `0` | 🍎🅶🤖🌐 | Degrees clockwise from north. Animatable | +| `scale` | `number` | `1` | 🍎🅶🤖🌐 | Scale factor. Animatable | +| `rasterize` | `boolean` | `true` | 🍎🅶🤖 | Rasterize the custom view to a bitmap. `false` keeps a live view for content that animates internally | +| `centerOnPress` | `boolean` | `true` | 🍎🅶🤖🌐 | Center the map on the marker when tapped | +| `draggable` | `boolean` | `false` | 🍎🅶🤖🌐 | Long-press-and-drag. Update `coordinate` from `onDragEnd` | +| `callout` | `ReactElement \| ComponentType` | — | 🍎🅶🤖🌐 | Custom callout content. Falls back to `title`/`description` bubble when omitted | +| `calloutOptions` | `CalloutOptions` | `{ bubbled: true }` | 🍎🅶🤖🌐 | `bubbled: false` renders the content as a live, interactive view without native chrome. `offset: Point` nudges a non-bubbled callout from its default centered-above position (positive `y` moves down) | +| `name` | `string` | — | 🍎🅶🤖 | Debug label for native logs | +| `onPress` | `MarkerPressEvent` | — | 🍎🅶🤖🌐 | Tap. Also toggles the callout when one exists | +| `onDragStart` / `onDragChange` / `onDragEnd` | `MarkerDragEvent` | — | 🍎🅶🤖🌐 | Drag lifecycle | +| `children` | `ReactNode` | — | 🍎🅶🤖🌐 | Custom marker view. Omit for the platform's default pin | + +Callout rendering by platform: + +| | Bubbled (`default`) | Non-bubbled | +|-|---------------------|-------------| +| 🍎 | Native `MKAnnotationView` callout, live content | Live view attached to the annotation | +| 🅶 🤖 | Info window — content **rasterized**, not tappable | Live view positioned over the map | +| 🌐 | `InfoWindow` (live) | `InfoWindow` with chrome stripped | + +## Polyline + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `coordinates` | `Coordinate[]` | **required** | 🍎🅶🤖🌐 | Path | +| `strokeWidth` | `number` | — (`1` on web) | 🍎🅶🤖🌐 | Points | +| `strokeColors` | `ColorValue[]` | — | 🍎🅶🤖🌐 | One color = solid, many = gradient spread evenly along the path | +| `animated` | `boolean` | `false` | 🍎🅶🤖🌐 | Snake animation from start to end, looping. Static maps render it complete | +| `animatedOptions` | `PolylineAnimatedOptions` | — | 🍎🅶🤖🌐 | `{ duration?: 2150, easing?: 'linear' \| 'easeIn' \| 'easeOut' \| 'easeInOut', trailLength?: 1 (0–1), delay?: 0 }` | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | Layering. Web defaults animated polylines to `1` | + +No `onPress`. Polylines are `pointerEvents: 'none'`. + +## Polygon + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `coordinates` | `Coordinate[]` | **required** | 🍎🅶🤖🌐 | Outer ring | +| `holes` | `Coordinate[][]` | — | 🍎🅶🤖🌐 | Interior rings | +| `fillColor` | `ColorValue` | — | 🍎🅶🤖🌐 | | +| `strokeColor` | `ColorValue` | — | 🍎🅶🤖🌐 | | +| `strokeWidth` | `number` | — | 🍎🅶🤖🌐 | Points | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | | +| `onPress` | `() => void` | — | 🍎🅶🤖🌐 | Tap inside the fill (holes excluded) | + +## Circle + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `center` | `Coordinate` | **required** | 🍎🅶🤖🌐 | | +| `radius` | `number` | **required** | 🍎🅶🤖🌐 | **Meters** | +| `fillColor` | `ColorValue` | — | 🍎🅶🤖🌐 | | +| `strokeColor` | `ColorValue` | — | 🍎🅶🤖🌐 | | +| `strokeWidth` | `number` | — | 🍎🅶🤖🌐 | Points | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | | +| `onPress` | `() => void` | — | 🍎🅶🤖🌐 | | + +## GeoJson + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `geojson` | `FeatureCollection \| Feature \| Geometry` | **required** | 🍎🅶🤖🌐 | RFC 7946. Positions are `[lng, lat(, alt)]` | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | Applied to every rendered element | +| `renderMarker` | `(props: MarkerProps, feature) => ReactElement \| null` | — | 🍎🅶🤖🌐 | Point / MultiPoint | +| `renderPolyline` | `(props: PolylineProps, feature) => ReactElement \| null` | — | 🍎🅶🤖🌐 | LineString / MultiLineString | +| `renderPolygon` | `(props: PolygonProps, feature) => ReactElement \| null` | — | 🍎🅶🤖🌐 | Polygon / MultiPolygon | + +Geometry → component: Point → `Marker`, MultiPoint → n × `Marker`, LineString → `Polyline`, MultiLineString → n × `Polyline`, Polygon → `Polygon` (first ring outer, rest holes), MultiPolygon → n × `Polygon`, GeometryCollection → recursive. + +simplestyle `feature.properties` → props: `title`, `description` (Marker); `stroke` → `strokeColors[0]` / `strokeColor`; `stroke-width` → `strokeWidth`; `fill` → `fillColor`. Render callbacks receive these as `props` and take precedence. `fill-opacity` / `stroke-opacity` are typed but not applied — bake alpha into the color. + +Keys: default elements are keyed by `feature.id` (or index). Elements returned from render callbacks must set their own `key`. + +## GroundOverlay + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `image` | `ImageSourcePropType` | **required** | 🍎🅶🤖🌐 | `require()` or `{ uri }` | +| `bounds` | `{ northeast: Coordinate; southwest: Coordinate }` | **required** | 🍎🅶🤖🌐 | | +| `opacity` | `number` | `1` | 🍎🅶🤖🌐 | 0–1 | +| `bearing` | `number` | `0` | 🅶🤖 | Degrees clockwise. Not supported on Apple Maps or web | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | | +| `onPress` | `() => void` | — | 🍎🅶🤖🌐 | | + +## TileOverlay + +| Prop | Type | Default | Platforms | Description | +|------|------|---------|-----------|-------------| +| `urlTemplate` | `string` | **required** | 🍎🅶🤖🌐 | `{x}`, `{y}`, `{z}` placeholders, e.g. `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | +| `tileSize` | `number` | `256` | 🍎🅶🤖🌐 | Pixels | +| `opacity` | `number` | `1` | 🍎🅶🤖🌐 | | +| `bounds` | `{ northeast; southwest }` | — | 🍎🅶🤖🌐 | Only load tiles inside | +| `zIndex` | `number` | — | 🍎🅶🤖🌐 | | +| `onPress` | `() => void` | — | 🌐 | Not supported by the native SDKs | + +Not rendered on iOS static maps. + +--- + +## Shared types + +```ts +interface Coordinate { latitude: number; longitude: number } +interface Point { x: number; y: number } +interface EdgeInsets { top: number; left: number; bottom: number; right: number } + +type MapProviderType = 'google' | 'apple'; +type MapType = 'standard' | 'satellite' | 'terrain' | 'hybrid' | 'muted-standard'; +type MapTheme = 'light' | 'dark' | 'system'; +type InsetAdjustment = 'automatic' | 'never'; + +interface PoiFilter { + mode?: 'including' | 'excluding'; // default 'including' + categories: PoiCategory[]; +} +``` + +`PoiCategory` (Apple Maps): `'airport' | 'amusement-park' | 'aquarium' | 'atm' | 'bakery' | 'bank' | 'beach' | 'brewery' | 'cafe' | 'campground' | 'car-rental' | 'ev-charger' | 'fire-station' | 'fitness-center' | 'food-market' | 'gas-station' | 'hospital' | 'hotel' | 'laundry' | 'library' | 'marina' | 'movie-theater' | 'museum' | 'national-park' | 'nightlife' | 'park' | 'parking' | 'pharmacy' | 'police' | 'post-office' | 'public-transport' | 'restaurant' | 'restroom' | 'school' | 'stadium' | 'store' | 'theater' | 'university' | 'winery' | 'zoo'` (iOS 13+) plus `'animal-service' | 'automotive-repair' | 'baseball' | 'basketball' | 'beauty' | 'bowling' | 'castle' | 'convention-center' | 'distillery' | 'fairground' | 'fishing' | 'fortress' | 'go-kart' | 'golf' | 'hiking' | 'kayaking' | 'landmark' | 'mailbox' | 'mini-golf' | 'music-venue' | 'national-monument' | 'planetarium' | 'rock-climbing' | 'rv-park' | 'skate-park' | 'skating' | 'skiing' | 'soccer' | 'spa' | 'surfing' | 'swimming' | 'tennis' | 'volleyball'` (iOS 18+). + +Exported from `@lugg/maps`: `MapView`, `MapProvider`, `Marker`, `Polyline`, `Polygon`, `Circle`, `GeoJson`, `GroundOverlay`, `TileOverlay`, and types `MapViewProps`, `MapViewRef`, `MapProviderProps`, `MapProviderType`, `MarkerProps`, `MarkerRef`, `CalloutOptions`, `PolylineProps`, `PolylineAnimatedOptions`, `PolylineEasing`, `PolygonProps`, `CircleProps`, `GeoJsonProps`, `GroundOverlayProps`, `GroundOverlayBounds`, `TileOverlayProps`, `TileOverlayBounds`, `MoveCameraOptions`, `FitCoordinatesOptions`, `SetEdgeInsetsOptions`, `MapCameraEvent`, `MapPressEvent`, `CameraEventPayload`, `PressEventPayload`, `MarkerPressEvent`, `MarkerDragEvent`, `MapType`, `MapTheme`, `InsetAdjustment`, `PoiCategory`, `PoiFilter`, `Coordinate`, `Point`, `EdgeInsets`, `GeoJSON`, `Feature`, `FeatureCollection`, `FeatureProperties`, `Geometry`, `Position`. diff --git a/skills/maps-usage/references/troubleshooting.md b/skills/maps-usage/references/troubleshooting.md new file mode 100644 index 0000000..5545456 --- /dev/null +++ b/skills/maps-usage/references/troubleshooting.md @@ -0,0 +1,215 @@ +# Troubleshooting + +Common issues with `@lugg/maps`, organized by symptom. + +## Table of Contents + +- [Map renders blank or zero height](#map-renders-blank-or-zero-height) +- [Blank / beige map with Google provider](#blank--beige-map-with-google-provider) +- [Google requested but Apple Maps shows (iOS)](#google-requested-but-apple-maps-shows-ios) +- [Changing `provider` or `staticMode` does nothing](#changing-provider-or-staticmode-does-nothing) +- [Camera doesn't move on mount](#camera-doesnt-move-on-mount) +- [Buttons inside a callout don't respond](#buttons-inside-a-callout-dont-respond) +- [Animated content inside a marker is frozen](#animated-content-inside-a-marker-is-frozen) +- [Marker snaps back after dragging](#marker-snaps-back-after-dragging) +- [Marker animation janks or re-renders every frame](#marker-animation-janks-or-re-renders-every-frame) +- [Overlapping markers stack wrong](#overlapping-markers-stack-wrong) +- [Center pin / selected coordinate is offset under a sheet](#center-pin--selected-coordinate-is-offset-under-a-sheet) +- [`e.nativeEvent.dragging` is undefined](#enativeeventdragging-is-undefined) +- [User location dot never appears](#user-location-dot-never-appears) +- [Static maps in a list are slow or crash the app](#static-maps-in-a-list-are-slow-or-crash-the-app) +- [Static map row shows a stale or missing image](#static-map-row-shows-a-stale-or-missing-image) +- [Static list map taps don't fire](#static-list-map-taps-dont-fire) +- [`mapType` / `mapId` ignored on Android list maps](#maptype--mapid-ignored-on-android-list-maps) +- [GeoJSON renders in the wrong place](#geojson-renders-in-the-wrong-place) +- [GeoJSON key warnings or flicker](#geojson-key-warnings-or-flicker) +- [Polyline `onPress` doesn't exist](#polyline-onpress-doesnt-exist) +- [Web: `MapProvider` errors or nothing renders](#web-mapprovider-errors-or-nothing-renders) +- [Web: `Animated.createAnimatedComponent(Marker)` doesn't animate](#web-animatedcreateanimatedcomponentmarker-doesnt-animate) +- [Web: import of `MapProviderType` / `MarkerRef` fails typecheck](#web-import-of-mapprovidertype--markerref-fails-typecheck) +- [Build fails: New Architecture / Codegen](#build-fails-new-architecture--codegen) +- [iOS Apple-only build still links GoogleMaps](#ios-apple-only-build-still-links-googlemaps) + +--- + +## Map renders blank or zero height + +**Symptom:** Nothing shows, or the map is a thin strip. + +**Cause:** `MapView` has no intrinsic size. + +**Fix:** Give it `style={{ flex: 1 }}`, a fixed `height`, or `StyleSheet.absoluteFill` inside a sized parent. In lists, wrap in a `View` with an explicit height. + +## Blank / beige map with Google provider + +**Symptom:** Grid or empty tiles, "For development purposes only" watermark, or console auth errors. + +**Cause:** Missing or misconfigured API key, or the key lacks the platform's SDK (Maps SDK for iOS / Android / Maps JavaScript API), or restrictions don't match the bundle id / package name / referrer. + +**Fix:** Configure the key per platform (see [Configuration → Setup](./configuration.md#setup)). After changing the Expo plugin config, run `npx expo prebuild --clean` and rebuild — keys are baked into native projects. + +## Google requested but Apple Maps shows (iOS) + +**Symptom:** `provider="google"` renders Apple Maps and logs `Google Maps SDK is excluded`. + +**Cause:** `$LuggMapsGoogleEnabled = false` in the Podfile or `iosGoogleMapsEnabled: false` in the plugin. + +**Fix:** Remove the flag / set it to `true`, add the API key back, `pod install` (or prebuild), rebuild. + +## Changing `provider` or `staticMode` does nothing + +**Cause:** Both are applied when the native map is created. + +**Fix:** Remount with a `key`: + +```tsx + +``` + +## Camera doesn't move on mount + +**Symptom:** `moveCamera` / `fitCoordinates` in `useEffect` is a no-op. + +**Cause:** Called before the native map exists. + +**Fix:** Call from `onReady`, or set `initialCoordinate` / `initialZoom` (compute a [fitted camera](./advanced-patterns.md#fitted-camera-for-static-maps) if you need to fit multiple points up front). + +## Buttons inside a callout don't respond + +**Symptom:** Custom `callout` content shows, but `Pressable`s inside do nothing on Google Maps (iOS/Android). + +**Cause:** Bubbled callouts are info windows, which Google Maps renders as bitmaps. + +**Fix:** `calloutOptions={{ bubbled: false }}` renders the content as a live view. Style your own card (background, radius, shadow) and nudge with `offset`. + +## Animated content inside a marker is frozen + +**Symptom:** Lottie / spinner / pulsing view inside `` shows a single frame. + +**Cause:** Custom marker views are rasterized by default. + +**Fix:** `rasterize={false}` on that marker only. Keep the default for everything else — live marker views cost more during pan/zoom. + +## Marker snaps back after dragging + +**Cause:** The `coordinate` prop wasn't updated, so the next render resets the native position. + +**Fix:** + +```tsx + setCoord(e.nativeEvent.coordinate)} /> +``` + +## Marker animation janks or re-renders every frame + +**Cause:** Driving `coordinate` with `setState` on every tick. + +**Fix:** Use Reanimated `animatedProps` on `Animated.createAnimatedComponent(Marker)` — see [Animated marker](./advanced-patterns.md#animated-marker-along-a-route). Also keep `onCameraMove` handlers light; use `onCameraIdle` for state. + +## Overlapping markers stack wrong + +**Fix:** `zIndex={Math.round((90 - latitude) * 10000)}` so southern markers are in front; bump the selected one higher. + +## Center pin / selected coordinate is offset under a sheet + +**Symptom:** The coordinate you compute for a fixed center pin is south of where the pin appears. + +**Cause:** The map is shrunk or the sheet is ignored, so the visual center and the map center differ. + +**Fix:** Keep the map full-size and push the viewport with `setEdgeInsets({ bottom: sheetHeight })`. Camera events then report the inset-adjusted center, and a pin translated up by `bottom / 2` sits exactly on it. See [Bottom sheet pattern](./advanced-patterns.md#bottom-sheet-with-edge-insets-and-center-pin). + +## `e.nativeEvent.dragging` is undefined + +**Cause:** The camera payload field is `gesture`, not `dragging`. + +**Fix:** `const { coordinate, zoom, gesture } = e.nativeEvent;` + +## User location dot never appears + +**Causes and fixes:** + +1. Permission not granted — the library never prompts. Request first, then set `userLocationEnabled`. +2. Android: the prop was set *before* the grant. Re-set it after (`userLocationEnabled={granted}`). +3. iOS: missing `NSLocationWhenInUseUsageDescription` in Info.plist. +4. Simulator has no location — set one in Features → Location (iOS) or the emulator's Extended Controls. +5. `userLocationButtonEnabled` is Android-only; on iOS build your own button that calls `moveCamera` with the device location. + +## Static maps in a list are slow or crash the app + +**Cause:** Too many rows mounted at once — every mounted static map holds a bitmap, and iOS Google warms up a live map per row. + +**Fix:** Bound the list: `windowSize={5}`, `initialNumToRender={4}`, `maxToRenderPerBatch={4}`, `removeClippedSubviews`. Keep row maps small (≈140 pt). Set `staticKey` so iOS reuses images when rows recycle. + +## Static map row shows a stale or missing image + +**Symptoms / fixes:** + +- **Stale content** after data changed — `staticKey` must uniquely identify the content including markers. Include a version or hash: `staticKey={`${place.id}-${place.updatedAt}`}`. +- **Never caches / re-renders each scroll** — children aren't deterministic (random offsets, `Date.now()` in render). Make them pure per key. +- **Blank after being offline** (iOS Google) — the base map only caches once tiles load; the row retries when it reappears. Offer a button that calls `reload()`. +- **Android view larger than ~2048 px** — lite mode can't render it; the map falls back to a full, gesture-less map. Keep static maps small. + +## Static list map taps don't fire + +**Cause:** `onPress` is disabled in `staticMode`, and the native map view intercepts touches. + +**Fix:** `` around a `` that contains the map. + +## `mapType` / `mapId` ignored on Android list maps + +**Cause:** Android lite mode doesn't support cloud styling; iOS static maps ignore map-setting prop changes after the snapshot. + +**Fix:** Accept the default style for lite maps, or render a small live map with all gestures disabled (`zoomEnabled={false} scrollEnabled={false} rotateEnabled={false} pitchEnabled={false}`) when styling matters more than memory. + +## GeoJSON renders in the wrong place + +**Cause:** Coordinates were authored as `[lat, lng]`. + +**Fix:** GeoJSON positions are `[longitude, latitude]`. Swap them at the source, not in a render callback. + +## GeoJSON key warnings or flicker + +**Cause:** Render callbacks returned elements without `key`, or `geojson` / callbacks are recreated every render (the component is memoized on them). + +**Fix:** Add `key={String(feature.id ?? index)}` to returned elements; wrap `geojson` in `useMemo` and callbacks in `useCallback`. + +## Polyline `onPress` doesn't exist + +**Cause:** Polylines are non-interactive on every platform. + +**Fix:** Overlay a transparent `Polygon` buffer around the line, or place tappable `Marker`s along it. + +## Web: `MapProvider` errors or nothing renders + +**Checks:** + +1. `@vis.gl/react-google-maps` installed (optional peer dep). +2. `` wraps the tree — on web it *is* the Google `APIProvider`. +3. The key has Maps JavaScript API enabled and an HTTP referrer restriction matching your origin. +4. Markers need a `mapId` (Advanced Markers). The default `DEMO_MAP_ID` works for development; set your own for production. + +## Web: `Animated.createAnimatedComponent(Marker)` doesn't animate + +**Cause:** `animatedProps` on `Marker` is native-only. + +**Fix:** Add a `.web.tsx` variant that animates `coordinate` / `rotate` with `requestAnimationFrame` + state. + +## Web: import of `MapProviderType` / `MarkerRef` fails typecheck + +**Cause:** Bundlers resolving the `web` export condition get `index.web.d.ts`, which exports a smaller type surface than native. + +**Fix:** Import shared primitives (`Coordinate`, `Point`, `EdgeInsets`, `MapViewRef`, `MapViewProps`, `MarkerProps`) which exist on both, or define the ref type locally (`{ showCallout(): void; hideCallout(): void }`). + +## Build fails: New Architecture / Codegen + +**Symptom:** `LuggMapView was not found in the UIManager`, or missing `RNMapsSpec` symbols. + +**Cause:** New Architecture disabled, or pods/gradle not regenerated after install. + +**Fix:** Enable Fabric (`newArchEnabled=true` in `gradle.properties`, `RCT_NEW_ARCH_ENABLED=1` for pods — default in RN 0.76+ / Expo SDK 52+). Then `pod install` / Gradle sync and a clean native rebuild. Expo: `npx expo prebuild --clean`. + +## iOS Apple-only build still links GoogleMaps + +**Cause:** The Podfile flag isn't at the top, or `AppDelegate.swift` still imports `GoogleMaps`. + +**Fix:** `$LuggMapsGoogleEnabled = false` must be the first line of the `Podfile`; remove the import and `provideAPIKey`; `pod install` again. With Expo, set `iosGoogleMapsEnabled: false` and re-run `npx expo prebuild --platform ios` (the plugin edits both files).