-
-
Notifications
You must be signed in to change notification settings - Fork 927
Open
Labels
bug 🪲Something isn't workingSomething isn't working
Description
Mapbox Implementation
Mapbox
Mapbox Version
default
React Native Version
0.79.5
React Native Architecture
Old Architecture (Paper/bridge)
Platform
Android
@rnmapbox/maps version
10.2.9
Standalone component to reproduce
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
TouchableOpacityProps,
View,
} from 'react-native';
import { GeoJSON} from 'geojson';
import Mapbox, { CircleLayer, FillLayer, ShapeSource } from '@rnmapbox/maps';
const INSTRUCTIONS = `
––– INSTRUCTIONS –––
Pressing any of the 4 shapes should:
1. return the geographic and screen coordinates
2. correctly convert screen coordinates to geographic coordinates
3. correctly convert geographic coordinates to screen coordinates
4. return the pressed feature (check "properties.color")
5. return the pressed feature (check "properties.color")
Pressing the red outline should yield these coordinates:
- top-left corner:
- screenPointY: 300
- screenPointX: 100
- bottom-right corner:
- screenPointY: 400
- screenPointX: 200
Test all of the above at different zoom levels, rotation, and heading.
`;
const STEPS = INSTRUCTIONS.replaceAll(/^(?!\s*\d\.).*\n?/gm, '')
.split('\n')
.map((s) => s.trim());
const ScreenCoordinates = () => {
const mapRef = useRef<Mapbox.MapView>(null);
const cameraRef = useRef<Mapbox.Camera>(null);
const [centerLon, setCenterLon] = useState<number>();
const [centerLat, setCenterLat] = useState<number>();
const [zoom, setZoom] = useState<number>();
const [heading, setHeading] = useState<number>();
const [pitch, setPitch] = useState<number>();
type ValuesRef = {
centerLon?: number;
centerLat?: number;
zoom?: number;
heading?: number;
pitch?: number;
};
const valuesRef = useRef<ValuesRef>({});
const centerText = useMemo(
() =>
`Center: ${
centerLon === undefined || centerLat === undefined
? '-'
: `${centerLon.toFixed(2)}, ${centerLat.toFixed(2)}`
}`,
[centerLon, centerLat],
);
const zoomText = useMemo(
() => `Zoom: ${zoom === undefined ? '-' : zoom.toFixed(0)}`,
[zoom],
);
const headingText = useMemo(
() => `Heading: ${heading === undefined ? '-' : heading.toFixed(0)}`,
[heading],
);
const pitchText = useMemo(
() => `Pitch: ${pitch === undefined ? '-' : pitch.toFixed(0)}`,
[pitch],
);
const [pressText, setPressText] = useState<string>();
const [coordinateFromViewText, setCoordinateFromViewText] =
useState<string>();
const [pointInViewText, setPointInViewText] = useState<string>();
const [queryRenderdFeaturesAtPointText, setQueryRenderedFeaturesAtPointText] =
useState<string>();
const [queryRenderdFeaturesInRectText, setQueryRenderedFeaturesInRectText] =
useState<string>();
const debugText = useMemo(() => {
return [
[
pressText,
coordinateFromViewText,
pointInViewText,
queryRenderdFeaturesAtPointText,
queryRenderdFeaturesInRectText,
]
.flatMap((v, i) => (v ? [`${STEPS[i]}\n\n${v}`] : []))
.join('\n\n'),
INSTRUCTIONS,
]
.flatMap((v) => (v ? [v] : []))
.join('\n\n');
}, [
pressText,
coordinateFromViewText,
pointInViewText,
queryRenderdFeaturesAtPointText,
queryRenderdFeaturesInRectText,
]);
const queryPressFeatures = useCallback(
(payload: ScreenPointPayload) => {
setCoordinateFromViewText(undefined);
setPointInViewText(undefined);
setQueryRenderedFeaturesAtPointText(undefined);
setQueryRenderedFeaturesInRectText(undefined);
const map = mapRef.current;
if (!map) return;
const point: GeoJSON.Position = [
payload.screenPointX,
payload.screenPointY,
];
const rectSize = 40;
const rect: GeoJSON.BBox = [
payload.screenPointY - rectSize / 2,
payload.screenPointX - rectSize / 2,
payload.screenPointY + rectSize / 2,
payload.screenPointX + rectSize / 2,
];
const pointText = `[ ${point.map((v) => v.toFixed(1)).join(', ')} ]`;
const rectText = `[ ${rect.map((v) => v.toFixed(1)).join(', ')} ]`;
const getPrettyPointText = (p: GeoJSON.Position) =>
`[\n${p
.map((v, i) => ` (${i === 0 ? 'x' : 'y'}) ${v.toFixed(1)}`)
.join(',\n')}\n]`;
const getPrettyCoordsText = (c: GeoJSON.Position) =>
`[\n${c
.map((v, i) => ` (${i === 0 ? 'lon' : 'lat'}) ${v.toFixed(4)}`)
.join(',\n')}\n]`;
mapRef.current
?.getCoordinateFromView(point)
.then((coords) => {
setCoordinateFromViewText(
`getCoordinateFromView(${getPrettyPointText(
point,
)}) -> ${getPrettyCoordsText(coords)}`,
);
mapRef.current
?.getPointInView(coords)
.then((p) => {
setPointInViewText(
`getPointInView(${getPrettyCoordsText(
coords,
)}) -> ${getPrettyPointText(p)}`,
);
})
.catch(() => {});
})
.catch(() => {});
mapRef.current
?.queryRenderedFeaturesAtPoint(point, undefined, [
POINTS_CIRCLE_LAYER_ID,
SQUARES_FILL_LAYER_ID,
])
.then((fc) => {
setQueryRenderedFeaturesAtPointText(
`queryRenderedFeaturesAtPoint(${pointText}): ${JSON.stringify(
fc,
undefined,
2,
)}`,
);
})
.catch(() => {});
mapRef.current
?.queryRenderedFeaturesInRect(rect, undefined, [
POINTS_CIRCLE_LAYER_ID,
SQUARES_FILL_LAYER_ID,
])
.then((fc) => {
setQueryRenderedFeaturesInRectText(
`queryRenderedFeaturesInRect(${rectText}): ${JSON.stringify(
fc,
undefined,
2,
)}`,
);
})
.catch(() => {});
},
[],
);
const handlePress = useCallback(
(f: GeoJSON.Feature<GeoJSON.Point, ScreenPointPayload>) => {
setPressText(`onPress: ${JSON.stringify(f, undefined, 2)}`);
queryPressFeatures(f.properties);
},
[queryPressFeatures],
);
const handleLongPress = useCallback(
(f: GeoJSON.Feature<GeoJSON.Point, ScreenPointPayload>) => {
setPressText(`onLongPress: ${JSON.stringify(f, undefined, 2)}`);
queryPressFeatures(f.properties);
},
[queryPressFeatures],
);
const [isBusy, setBusy] = useState(true);
const updateValues = useCallback((state: Mapbox.MapState) => {
const centerLon = state.properties.center[0] ?? 0;
const centerLat = state.properties.center[1] ?? 0;
const zoom = state.properties.zoom;
const heading = state.properties.heading;
const pitch = state.properties.pitch;
valuesRef.current.centerLon = centerLon;
valuesRef.current.centerLat = centerLat;
valuesRef.current.zoom = zoom;
valuesRef.current.heading = heading;
valuesRef.current.pitch = pitch;
setCenterLon(centerLon);
setCenterLat(centerLat);
setZoom(zoom);
setHeading(heading);
setPitch(pitch);
}, [])
const handleCameraChanged = useCallback((state: Mapbox.MapState) => {
updateValues(state);
}, [updateValues]);
const handleMapIdle = useCallback((state: Mapbox.MapState) => {
updateValues(state);
setBusy(false);
}, [updateValues]);
const setCameraStop = useCallback((stop: Mapbox.CameraStop) => {
setBusy(true);
cameraRef.current?.setCamera({
animationMode: 'easeTo',
animationDuration: 300,
...stop,
});
}, []);
const handlePressCenter = useCallback(() => {
const { centerLon, centerLat } = valuesRef.current;
if (centerLon === undefined || centerLat === undefined) return;
setCameraStop({
centerCoordinate:
centerLon !== DEFAULT_CAMERA_STOP.centerCoordinate[0] ||
centerLat !== DEFAULT_CAMERA_STOP.centerCoordinate[1]
? DEFAULT_CAMERA_STOP.centerCoordinate
: INITIAL_CAMERA_STOP.centerCoordinate,
});
}, [setCameraStop]);
const handlePressZoom = useCallback(() => {
const { zoom } = valuesRef.current;
setCameraStop({
zoomLevel:
zoom !== DEFAULT_CAMERA_STOP.zoomLevel
? DEFAULT_CAMERA_STOP.zoomLevel
: INITIAL_CAMERA_STOP.zoomLevel,
});
}, [setCameraStop]);
const handlePressHeading = useCallback(() => {
const { heading } = valuesRef.current;
setCameraStop({
heading:
heading !== DEFAULT_CAMERA_STOP.heading
? DEFAULT_CAMERA_STOP.heading
: INITIAL_CAMERA_STOP.heading,
});
}, [setCameraStop]);
const handlePressPitch = useCallback(() => {
const { pitch } = valuesRef.current;
setCameraStop({
pitch:
pitch !== DEFAULT_CAMERA_STOP.pitch
? DEFAULT_CAMERA_STOP.pitch
: INITIAL_CAMERA_STOP.pitch,
});
}, [setCameraStop]);
return (
<View style={styles.container}>
<Mapbox.MapView
ref={mapRef}
testID="screen-coordinates"
style={styles.map}
// map configuration
projection="mercator"
styleURL={Mapbox.StyleURL.Light}
zoomEnabled={true}
scrollEnabled={true}
pitchEnabled={true}
rotateEnabled={true}
compassEnabled={true}
compassViewPosition={1}
compassFadeWhenNorth={true}
scaleBarEnabled={false}
attributionEnabled={false}
logoEnabled={false}
// handlers
onPress={handlePress}
onLongPress={handleLongPress}
onMapIdle={handleMapIdle}
onCameraChanged={handleCameraChanged}
>
<Mapbox.Camera ref={cameraRef} defaultSettings={INITIAL_CAMERA_STOP} />
<ShapeSource id={POINTS_SOURCE_ID} shape={POINTS_SOURCE}>
<CircleLayer
id={POINTS_CIRCLE_LAYER_ID}
style={mapStyles.pointCircle}
/>
</ShapeSource>
<ShapeSource id={SQUARES_SOURCE_ID} shape={SQUARES_SOURCE}>
<FillLayer id={SQUARES_FILL_LAYER_ID} style={mapStyles.squareFill} />
</ShapeSource>
</Mapbox.MapView>
<View style={styles.actionsContainer}>
<ActionButton
title={centerText}
size="wide"
onPress={handlePressCenter}
disabled={isBusy}
/>
<ActionButton
title={zoomText}
onPress={handlePressZoom}
disabled={isBusy}
/>
<ActionButton
title={headingText}
onPress={handlePressHeading}
disabled={isBusy}
/>
<ActionButton
title={pitchText}
onPress={handlePressPitch}
disabled={isBusy}
/>
</View>
<ScrollView
style={styles.debugTextContainer}
contentContainerStyle={styles.debugTextContentContainer}
>
<Text style={styles.debugText}>{debugText}</Text>
</ScrollView>
<View style={styles.overlay} />
</View>
);
};
const INITIAL_CAMERA_STOP = {
centerCoordinate: [-5, 20],
zoomLevel: 2,
heading: 15,
pitch: 45,
} as const satisfies Mapbox.CameraStop;
const DEFAULT_CAMERA_STOP = {
centerCoordinate: [0, 0],
zoomLevel: 0,
heading: 0,
pitch: 0,
} as const satisfies Mapbox.CameraStop;
const POINT_GEO_CENTERS: readonly GeoJSON.Position[] = [
[10, 10],
[30, 30],
];
const SQUARES_GEO_CENTERS: readonly GeoJSON.Position[] = [
[-10, 10],
[-30, 30],
];
const OUTLINE_RECT = {
x: 100,
y: 300,
width: 100,
height: 100,
} as const;
type ScreenPointPayload = {
readonly screenPointX: number;
readonly screenPointY: number;
};
type CustomProperties = {
readonly color: string;
};
const POINTS_SOURCE: GeoJSON.FeatureCollection<
GeoJSON.Point,
CustomProperties
> = {
type: 'FeatureCollection',
features: POINT_GEO_CENTERS.map(
(coordinates, i): GeoJSON.Feature<GeoJSON.Point, CustomProperties> => ({
type: 'Feature',
id: `point-lon:${(coordinates[0] ?? 0).toFixed(4)},lat:${(
coordinates[1] ?? 0
).toFixed(4)}`,
properties: { color: i === 0 ? 'magenta' : 'orange' },
geometry: { type: 'Point', coordinates },
}),
),
};
const SQUARE_SIZE = 5;
const SQUARES_SOURCE: GeoJSON.FeatureCollection<
GeoJSON.Polygon,
CustomProperties
> = {
type: 'FeatureCollection',
features: SQUARES_GEO_CENTERS.map(
(coords, i): GeoJSON.Feature<GeoJSON.Polygon, CustomProperties> => {
const lon = coords[0] ?? 0;
const lat = coords[1] ?? 0;
const r = SQUARE_SIZE / 2;
return {
type: 'Feature',
id: `square-lon:${lon.toFixed(4)},lat:${lat.toFixed(4)}`,
properties: { color: i === 0 ? 'blue' : 'green' },
geometry: {
type: 'Polygon',
coordinates: [
[
[lon + r, lat + r],
[lon + r, lat - r],
[lon - r, lat - r],
[lon - r, lat + r],
[lon + r, lat + r],
],
],
},
};
},
),
};
const POINTS_SOURCE_ID = 'points';
const POINTS_CIRCLE_LAYER_ID = `${POINTS_SOURCE_ID}-circle`;
const SQUARES_SOURCE_ID = 'squares';
const SQUARES_FILL_LAYER_ID = `${SQUARES_SOURCE_ID}-fill`;
const mapStyles = {
pointCircle: {
circleRadius: 10,
circleOpacity: 0.5,
circleColor: ['get', 'color'],
},
squareFill: {
fillColor: ['get', 'color'],
fillOpacity: 0.5,
},
} as const;
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
height: 450,
},
overlay: {
pointerEvents: 'none',
position: 'absolute',
left: OUTLINE_RECT.x,
top: OUTLINE_RECT.y,
width: OUTLINE_RECT.width,
height: OUTLINE_RECT.height,
borderWidth: 1,
borderColor: '#ff000040',
},
actionsContainer: {
flexDirection: 'row',
padding: 4,
gap: 8,
backgroundColor: 'gray',
},
actionButton: {
backgroundColor: 'white',
borderRadius: 4,
paddingVertical: 2,
paddingHorizontal: 4,
},
actionButtonFlexDefault: {
flex: 1,
},
actionButtonFlexWide: {
flex: 2,
},
actionButtonText: {
fontSize: 12,
color: 'black',
},
debugTextContainer: {
flex: 1,
backgroundColor: 'white',
},
debugTextContentContainer: {
padding: 16,
paddingBottom: 34,
},
debugText: {
fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }),
fontWeight: 'bold',
fontSize: 10,
color: '#000000',
},
});
type ActionButtonProps = TouchableOpacityProps & {
readonly title: string;
readonly size?: 'default' | 'wide';
};
const ActionButton = (props: ActionButtonProps) => {
const { title, size = 'default', style, ...rest } = props;
return (
<TouchableOpacity
style={[
styles.actionButton,
size === 'wide'
? styles.actionButtonFlexWide
: styles.actionButtonFlexDefault,
style,
]}
{...rest}
>
<Text style={styles.actionButtonText}>{title}</Text>
</TouchableOpacity>
);
};Observed behavior and steps to reproduce
On Android, screen coordinates are incorrectly converted between physical pixels (px) and density-independent pixels (dp) for the following MapView functions:
getCoordinateFromView(point): expects point in px; should expect point in dpgetPointInView(coords): returns point in px; should return point in dpqueryRenderedFeaturesAtPoint(point): expects point in px; should expect point in dpqueryRenderedFeaturesInRect(bbox): expects bbox in px; should expect bbox dponPress&onLongPress: returns screen coords in px; should return dp
Expected behavior
Screen coordinates should be specified and returned in density-independent pixels.
Notes / preliminary analysis
I have a fix ready and I'll open a PR shortly.
Additional links and references
No response
Metadata
Metadata
Assignees
Labels
bug 🪲Something isn't workingSomething isn't working