Map: render meters as a GL CircleLayer instead of MarkerView

MarkerView (React overlays repositioned per frame) caused the pins to float and
the camera to snap back to the user on pan/zoom/tap. Meters are now a ShapeSource
+ CircleLayer (GPU-drawn, coordinate-anchored); tapping a circle opens its parking
flow via the source's native onPress. No React overlays = no floating, no camera thrash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 10:46:10 -07:00
parent b0c343ab20
commit 3837200d89

View file

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import {
MapView,
Camera,
MarkerView,
ShapeSource,
CircleLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useNavigation } from '@react-navigation/native';
@ -148,6 +148,31 @@ export function MapScreen() {
await searchAt(last, 'last location');
};
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
// than React MarkerViews, which floated and thrashed the camera.
const meterFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: zones.map((z, i) => ({
type: 'Feature' as const,
id: String(z.ZoneId ?? z.ScannerCode ?? i),
geometry: {
type: 'Point' as const,
coordinates: [z.Long as number, z.Lat as number],
},
properties: { index: i, color: normalizeColor(z.BackgroundColor) ?? '#1e6f5c' },
})),
}),
[zones],
);
const onPinPress = (e: any) => {
const idx = e?.features?.[0]?.properties?.index;
if (typeof idx === 'number' && zones[idx]) {
navigation.navigate('MeterDetail', { zone: zones[idx] });
}
};
return (
<View style={styles.container}>
<MapView
@ -177,26 +202,26 @@ export function MapScreen() {
}}
/>
{zones.map((z) => (
<MarkerView
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
coordinate={[z.Long as number, z.Lat as number]}
anchor={{ x: 0.5, y: 1 }}
>
<TouchableOpacity
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons
name="location-sharp"
size={34}
color={normalizeColor(z.BackgroundColor) ?? '#1e6f5c'}
style={styles.pinShadow}
/>
</TouchableOpacity>
</MarkerView>
))}
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
<CircleLayer
id="meter-circles"
style={{
circleColor: ['get', 'color'],
circleStrokeColor: '#ffffff',
circleStrokeWidth: 2,
circlePitchAlignment: 'map',
circleRadius: [
'interpolate',
['linear'],
['zoom'],
10,
5,
16,
9,
],
}}
/>
</ShapeSource>
</MapView>
<View style={[styles.statusBar, { top: insets.top + 12 }]}>
@ -224,12 +249,6 @@ export function MapScreen() {
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
// White halo so the colored pin stays visible on both light and dark tiles.
pinShadow: {
textShadowColor: 'rgba(255,255,255,0.9)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 3,
},
statusBar: {
position: 'absolute',
top: 12,