diff --git a/__tests__/PilotMarkers.test.js b/__tests__/PilotMarkers.test.js
index 4a90ffb..9c9077a 100644
--- a/__tests__/PilotMarkers.test.js
+++ b/__tests__/PilotMarkers.test.js
@@ -12,7 +12,7 @@ import PilotMarkers, {pilotMarkerItemPropsEqual} from '../app/components/vatsimM
// Build a minimal Redux store matching the app shape
const makeStore = (pilots = [], selectedClient = null, myCid = '', friendCids = []) => {
return createStore(() => ({
- app: { selectedClient, myCid, friendCids },
+ app: { selectedClient, myCid, friendCids, iconCacheVersion: 1 },
vatsimLiveData: {
clients: { pilots },
},
@@ -193,21 +193,31 @@ describe('PilotMarkers role coloring', () => {
});
});
-describe('PilotMarkerItem memo with pilotRole', () => {
- it('returns false when pilotRole changes', () => {
+describe('PilotMarkerItem memo equality', () => {
+ it('returns false when myCid changes (role input changed)', () => {
const pilot = makePilot();
const onPress = jest.fn();
- const base = { pilot, pilotImage: pilot.image, pilotImageSize: pilot.imageSize, onPress, pilotRole: 'other' };
+ const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress };
expect(pilotMarkerItemPropsEqual(
- { ...base, pilotRole: 'other' },
- { ...base, pilotRole: 'me' }
+ { ...base, myCid: '' },
+ { ...base, myCid: String(pilot.cid) }
)).toBe(false);
});
- it('returns true when pilotRole is same', () => {
+ it('returns false when iconCacheVersion changes (theme changed)', () => {
const pilot = makePilot();
const onPress = jest.fn();
- const base = { pilot, pilotImage: pilot.image, pilotImageSize: pilot.imageSize, onPress, pilotRole: 'friend' };
+ const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress };
+ expect(pilotMarkerItemPropsEqual(
+ { ...base, iconCacheVersion: 1 },
+ { ...base, iconCacheVersion: 2 }
+ )).toBe(false);
+ });
+
+ it('returns true when all inputs are identical', () => {
+ const pilot = makePilot();
+ const onPress = jest.fn();
+ const base = { pilot, myCid: '', friendCids: [], iconCacheVersion: 1, onPress };
expect(pilotMarkerItemPropsEqual(base, base)).toBe(true);
});
});
diff --git a/app/components/vatsimMapView/AirportMarkers.jsx b/app/components/vatsimMapView/AirportMarkers.jsx
index 7c37187..20501e3 100644
--- a/app/components/vatsimMapView/AirportMarkers.jsx
+++ b/app/components/vatsimMapView/AirportMarkers.jsx
@@ -1,6 +1,6 @@
import {Circle, Marker, Polygon} from 'react-native-maps';
import {Image, Platform, StyleSheet} from 'react-native';
-import React, {useCallback, useRef} from 'react';
+import React, {useCallback, useMemo, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import allActions from '../../redux/actions';
import {APP, APP_RADIUS} from '../../common/consts';
@@ -67,74 +67,112 @@ const AirportMarkers = React.memo(function AirportMarkers({visible = true, zoomL
const traconPolygonCacheRef = useRef(new Map());
const appCircleCacheRef = useRef(new Map());
const staleTallyRef = useRef(new Map());
- const airportMarkers = [];
- const visibleTraconKeys = new Set();
- const visibleCircleKeys = new Set();
const onPress = useCallback((airport) => {
markNewSelection();
dispatch(allActions.appActions.clientSelected(airport));
}, [dispatch]);
- const renderedTracons = new Set();
- const zoomBand = getZoomBand(zoomLevel);
- const renderedStaffedIcaos = new Set();
+ const markers = useMemo(() => {
+ const airportMarkers = [];
+ const visibleTraconKeys = new Set();
+ const visibleCircleKeys = new Set();
- for (const icao in airportAtc) {
- const airport = getAirportByCode(icao, airports);
+ const renderedTracons = new Set();
+ const zoomBand = getZoomBand(zoomLevel);
+ const renderedStaffedIcaos = new Set();
- if (airport != null && airportAtc[airport.icao] && airportAtc[airport.icao].length > 0) {
- airportAtc[airport.icao].forEach(atc => {
- if (atc.facility === APP) {
- const callsignPrefix = atc.callsign.split('_')[0];
- const callsignSuffix = atc.callsign.split('_').pop();
- const tracon = lookupTracon(traconBoundaryLookup, callsignPrefix, callsignSuffix);
- if (tracon) {
- const traconKey = tracon.id;
- if (!renderedTracons.has(traconKey)) {
- renderedTracons.add(traconKey);
- tracon.polygons.forEach((poly, i) => {
- const overlayKey = `${traconKey}-polygon-${i}`;
- traconPolygonCacheRef.current.set(overlayKey, {
- coordinates: poly.coordinates,
- holes: poly.holes,
- airport,
+ for (const icao in airportAtc) {
+ const airport = getAirportByCode(icao, airports);
+
+ if (airport != null && airportAtc[airport.icao] && airportAtc[airport.icao].length > 0) {
+ airportAtc[airport.icao].forEach(atc => {
+ if (atc.facility === APP) {
+ const callsignPrefix = atc.callsign.split('_')[0];
+ const callsignSuffix = atc.callsign.split('_').pop();
+ const tracon = lookupTracon(traconBoundaryLookup, callsignPrefix, callsignSuffix);
+ if (tracon) {
+ const traconKey = tracon.id;
+ if (!renderedTracons.has(traconKey)) {
+ renderedTracons.add(traconKey);
+ tracon.polygons.forEach((poly, i) => {
+ const overlayKey = `${traconKey}-polygon-${i}`;
+ traconPolygonCacheRef.current.set(overlayKey, {
+ coordinates: poly.coordinates,
+ holes: poly.holes,
+ airport,
+ });
+ visibleTraconKeys.add(overlayKey);
});
- visibleTraconKeys.add(overlayKey);
+ }
+ } else {
+ const circleKey = `${atc.callsign}-app-circle`;
+ appCircleCacheRef.current.set(circleKey, {
+ center: {latitude: atc.latitude, longitude: atc.longitude},
+ title: atc.callsign,
});
+ visibleCircleKeys.add(circleKey);
}
+ }
+ });
+
+ renderedStaffedIcaos.add(airport.icao);
+
+ if (visible) {
+ const traffic = trafficCounts ? trafficCounts[airport.icao] : null;
+ const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport';
+ if (useViewMarker) {
+ airportMarkers.push(
+
+ );
} else {
- const circleKey = `${atc.callsign}-app-circle`;
- appCircleCacheRef.current.set(circleKey, {
- center: {latitude: atc.latitude, longitude: atc.longitude},
- title: atc.callsign,
- });
- visibleCircleKeys.add(circleKey);
+ const markerImage = getStaffedMarkerImage(airport.icao, zoomBand, activeTheme, null);
+ airportMarkers.push(
+
+ );
}
}
- });
+ }
+ }
- renderedStaffedIcaos.add(airport.icao);
+ // Render unstaffed airports with traffic (hidden at global zoom)
+ if (visible && zoomBand !== 'global' && trafficCounts) {
+ const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport';
+ for (const icao in trafficCounts) {
+ if (renderedStaffedIcaos.has(icao)) continue;
+ const airport = getAirportByCode(icao, airports);
+ if (!airport) continue;
+ const traffic = trafficCounts[icao];
+ if (!traffic || (traffic.departures === 0 && traffic.arrivals === 0)) continue;
- if (visible) {
- const traffic = trafficCounts ? trafficCounts[airport.icao] : null;
- const useViewMarker = zoomBand === 'continental' || zoomBand === 'regional' || zoomBand === 'local' || zoomBand === 'airport';
if (useViewMarker) {
airportMarkers.push(
);
} else {
- const markerImage = getStaffedMarkerImage(airport.icao, zoomBand, activeTheme, null);
+ const markerImage = getTrafficMarkerImage(icao, traffic.departures, traffic.arrivals, zoomBand, activeTheme);
airportMarkers.push(
- );
- } else {
- const markerImage = getTrafficMarkerImage(icao, traffic.departures, traffic.arrivals, zoomBand, activeTheme);
- airportMarkers.push(
-
- );
- }
- }
- }
- // Render cached TRACON polygons, evict stale ones
- traconPolygonCacheRef.current.forEach((overlay, overlayKey) => {
- if (visibleTraconKeys.has(overlayKey)) {
- staleTallyRef.current.delete(overlayKey);
- airportMarkers.push(
- onPress(overlay.airport)}
- />
- );
- } else {
- const tally = (staleTallyRef.current.get(overlayKey) || 0) + 1;
- if (tally > STALE_EVICT_THRESHOLD) {
- traconPolygonCacheRef.current.delete(overlayKey);
+ // Render cached TRACON polygons, evict stale ones
+ traconPolygonCacheRef.current.forEach((overlay, overlayKey) => {
+ if (visibleTraconKeys.has(overlayKey)) {
staleTallyRef.current.delete(overlayKey);
- } else {
- staleTallyRef.current.set(overlayKey, tally);
airportMarkers.push(
onPress(overlay.airport)}
/>
);
+ } else {
+ const tally = (staleTallyRef.current.get(overlayKey) || 0) + 1;
+ if (tally > STALE_EVICT_THRESHOLD) {
+ traconPolygonCacheRef.current.delete(overlayKey);
+ staleTallyRef.current.delete(overlayKey);
+ } else {
+ staleTallyRef.current.set(overlayKey, tally);
+ airportMarkers.push(
+
+ );
+ }
}
- }
- });
+ });
- // Render cached APP circles, evict stale ones
- appCircleCacheRef.current.forEach((circle, circleKey) => {
- if (visibleCircleKeys.has(circleKey)) {
- staleTallyRef.current.delete(circleKey);
- airportMarkers.push(
-
- );
- } else {
- const tally = (staleTallyRef.current.get(circleKey) || 0) + 1;
- if (tally > STALE_EVICT_THRESHOLD) {
- appCircleCacheRef.current.delete(circleKey);
+ // Render cached APP circles, evict stale ones
+ appCircleCacheRef.current.forEach((circle, circleKey) => {
+ if (visibleCircleKeys.has(circleKey)) {
staleTallyRef.current.delete(circleKey);
- } else {
- staleTallyRef.current.set(circleKey, tally);
airportMarkers.push(
);
+ } else {
+ const tally = (staleTallyRef.current.get(circleKey) || 0) + 1;
+ if (tally > STALE_EVICT_THRESHOLD) {
+ appCircleCacheRef.current.delete(circleKey);
+ staleTallyRef.current.delete(circleKey);
+ } else {
+ staleTallyRef.current.set(circleKey, tally);
+ airportMarkers.push(
+
+ );
+ }
}
- }
- });
+ });
+
+ return airportMarkers;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [airportAtc, airports, traconBoundaryLookup, trafficCounts, zoomLevel, visible, activeTheme, onPress]);
- return <>{airportMarkers}>;
+ return <>{markers}>;
});
export default AirportMarkers;
diff --git a/app/components/vatsimMapView/CTRPolygons.jsx b/app/components/vatsimMapView/CTRPolygons.jsx
index 046f58b..dc81442 100644
--- a/app/components/vatsimMapView/CTRPolygons.jsx
+++ b/app/components/vatsimMapView/CTRPolygons.jsx
@@ -1,6 +1,6 @@
import {Marker, Polygon} from 'react-native-maps';
import {Text} from 'react-native';
-import React, {useRef} from 'react';
+import React, {useRef, useCallback} from 'react';
import {useTheme} from '../../common/ThemeProvider';
import {EXCLUDED_CALLSIGNS} from '../../common/consts';
import {useDispatch, useSelector} from 'react-redux';
@@ -77,10 +77,10 @@ const CTRPolygons = React.memo(function CTRPolygons({visible = true}) {
const polygons = [];
const activeKeys = new Set();
- let onPress = (client) => {
+ const onPress = useCallback((client) => {
markNewSelection();
dispatch(allActions.appActions.clientSelected(client));
- };
+ }, [dispatch]);
const getAirspaceCoordinates = client => {
let isOceanic = false;
diff --git a/app/components/vatsimMapView/ClusteredPilotMarkers.jsx b/app/components/vatsimMapView/ClusteredPilotMarkers.jsx
deleted file mode 100644
index dc33185..0000000
--- a/app/components/vatsimMapView/ClusteredPilotMarkers.jsx
+++ /dev/null
@@ -1,194 +0,0 @@
-import React, {useCallback, useMemo} from 'react';
-import {View, Text, Image, StyleSheet, Platform} from 'react-native';
-import { Marker } from 'react-native-maps';
-import { useDispatch, useSelector } from 'react-redux';
-import ClusterMapView from 'react-native-map-clustering';
-import allActions from '../../redux/actions';
-import {mapIcons} from '../../common/iconsHelper';
-import {useTheme} from '../../common/ThemeProvider';
-
-const isAndroid = Platform.OS === 'android';
-const defaultImageSize = isAndroid ? 64 : 32;
-
-// Custom cluster marker component
-const ClusterMarker = ({pointCount, coordinate, onPress, clusterBadgeStyle, clusterTextStyle}) => {
- const points = pointCount;
-
- return (
-
-
-
-
- {points}
-
-
-
-
- );
-};
-
-// Custom renderer for individual pilot markers
-const PilotMarker = ({pilot, onPress}) => {
- const pilotImage = pilot.image || mapIcons.B737;
- const pilotImageSize = pilot.image ? pilot.imageSize : defaultImageSize;
- const pilotImageStyle = useMemo(() => ({
- height: pilotImageSize,
- width: pilotImageSize,
- transform: [{rotate: `${pilot.heading}deg`}],
- }), [pilot.heading, pilotImageSize]);
-
- return isAndroid ? (
-
- ) : (
-
-
-
- );
-};
-
-const ClusteredPilotMarkers = () => {
- const dispatch = useDispatch();
- const selectedClient = useSelector(state => state.app.selectedClient);
- const pilots = useSelector(state => state.vatsimLiveData.clients.pilots);
- const {activeTheme} = useTheme();
- const clusterColor = activeTheme.accent.primary + 'CC';
- const clusterBadgeStyle = useMemo(() => ({
- backgroundColor: clusterColor,
- borderColor: activeTheme.surface.border,
- }), [activeTheme.surface.border, clusterColor]);
- const clusterTextStyle = useMemo(() => ({
- color: activeTheme.text.primary,
- }), [activeTheme.text.primary]);
-
- // Convert pilots to GeoJSON features for clustering
- const pilotFeatures = useMemo(() => {
- return pilots.map(pilot => ({
- type: 'Feature',
- properties: {
- id: pilot.callsign,
- pilot: pilot,
- },
- geometry: {
- type: 'Point',
- coordinates: [pilot.longitude, pilot.latitude],
- },
- }));
- }, [pilots]);
-
- const onMarkerPress = useCallback((pilot) => {
- if (selectedClient && pilot.callsign === selectedClient.callsign) {
- dispatch(allActions.appActions.clientSelected(null));
- } else {
- dispatch(allActions.appActions.clientSelected(pilot));
- }
- }, [selectedClient, dispatch]);
-
- // Custom render cluster function
- const renderCluster = useCallback((cluster) => {
- const { id, geometry, onPress, properties } = cluster;
- const points = properties.point_count;
-
- // For clusters with only one point, render the individual marker
- if (points === 1) {
- const pilot = properties.cluster
- ? properties.cluster[0].properties.pilot
- : properties.pilot;
-
- return (
- onMarkerPress(pilot)}
- />
- );
- }
-
- // For clusters with multiple points, render a cluster marker
- return (
-
- );
- }, [onMarkerPress, clusterBadgeStyle, clusterTextStyle]);
-
- if (!pilots.length) return null;
-
- return (
- {
- // Handle cluster press if needed
- }}
- />
- );
-};
-
-const styles = StyleSheet.create({
- mapStyle: {
- flex: 1,
- },
- clusterContainer: {
- width: 40,
- height: 40,
- alignItems: 'center',
- justifyContent: 'center',
- },
- clusterBackground: {
- width: 30,
- height: 30,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 2,
- borderColor: 'white',
- },
- clusterText: {
- color: 'white',
- fontWeight: 'bold',
- fontSize: 12,
- },
-});
-
-export default ClusteredPilotMarkers;
diff --git a/app/components/vatsimMapView/LocalAirportMarker.jsx b/app/components/vatsimMapView/LocalAirportMarker.jsx
index ab122f2..9326fb3 100644
--- a/app/components/vatsimMapView/LocalAirportMarker.jsx
+++ b/app/components/vatsimMapView/LocalAirportMarker.jsx
@@ -15,6 +15,22 @@ const DOT_SIZE = 10;
const DOT_GAP = 3;
const BADGE_TEXT_COLOR = '#FFFFFF';
+const localAirportMarkerEqual = (prev, next) => {
+ if (prev.airport.icao !== next.airport.icao) return false;
+ if (prev.trafficInfo?.departures !== next.trafficInfo?.departures) return false;
+ if (prev.trafficInfo?.arrivals !== next.trafficInfo?.arrivals) return false;
+ if (prev.activeTheme.atc !== next.activeTheme.atc) return false;
+ if (prev.onPress !== next.onPress) return false;
+ if ((prev.atcList?.length ?? 0) !== (next.atcList?.length ?? 0)) return false;
+ if (prev.atcList && next.atcList) {
+ for (let i = 0; i < prev.atcList.length; i++) {
+ if (prev.atcList[i].callsign !== next.atcList[i].callsign) return false;
+ if (prev.atcList[i].facility !== next.atcList[i].facility) return false;
+ }
+ }
+ return true;
+};
+
const LocalAirportMarker = React.memo(function LocalAirportMarker({
airport,
atcList,
@@ -123,7 +139,7 @@ const LocalAirportMarker = React.memo(function LocalAirportMarker({
);
-});
+}, localAirportMarkerEqual);
export default LocalAirportMarker;
diff --git a/app/components/vatsimMapView/PilotMarkers.jsx b/app/components/vatsimMapView/PilotMarkers.jsx
index 051649c..53d7bf5 100644
--- a/app/components/vatsimMapView/PilotMarkers.jsx
+++ b/app/components/vatsimMapView/PilotMarkers.jsx
@@ -1,15 +1,17 @@
import {Marker} from 'react-native-maps';
import {Image, Platform} from 'react-native';
-import React, {useCallback, useRef, useEffect} from 'react';
+import React, {useCallback, useRef, useEffect, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import allActions from '../../redux/actions';
import {markNewSelection} from '../detailPanel/DetailPanelProvider';
import {mapIcons, getPilotMarkerRole} from '../../common/iconsHelper';
-import {getMarkerImage, getCacheVersion} from '../../common/aircraftIconService';
+import {getMarkerImage} from '../../common/aircraftIconService';
import {getZoomBand, GROUND_SPEED_THRESHOLD} from '../../common/consts';
const isAndroid = Platform.OS === 'android';
+const selectIconCacheVersion = state => state.app.iconCacheVersion;
+
// ANDROID WORKAROUND: Native Google Maps markers can leave ghost bitmaps at
// old positions when react-native-maps updates coordinates with
// tracksViewChanges={false}. Including a coarse coordinate hash in the React
@@ -19,16 +21,31 @@ const coordKey = isAndroid
? (lat, lng) => `${Math.round(lat * 100)}_${Math.round(lng * 100)}`
: () => '';
+// Equality: skip re-render if position, heading, cid, aircraft type, and role
+// inputs haven't changed. Role and image are now resolved inside the item, so
+// we compare the inputs that drive them (cid, myCid, friendCids, aircraftType).
export const pilotMarkerItemPropsEqual = (prev, next) =>
prev.pilot.key === next.pilot.key &&
prev.pilot.latitude === next.pilot.latitude &&
prev.pilot.longitude === next.pilot.longitude &&
prev.pilot.heading === next.pilot.heading &&
- prev.pilotImage === next.pilotImage &&
- prev.pilotRole === next.pilotRole &&
+ prev.pilot.cid === next.pilot.cid &&
+ prev.pilot.flight_plan?.aircraft === next.pilot.flight_plan?.aircraft &&
+ prev.myCid === next.myCid &&
+ prev.friendCids === next.friendCids &&
+ prev.iconCacheVersion === next.iconCacheVersion &&
prev.onPress === next.onPress;
-const PilotMarkerItem = React.memo(({pilot, pilotImage, pilotImageSize, onPress, pilotRole: _pilotRole}) => {
+const defaultImageSize = isAndroid ? 64 : 32;
+
+// Role and image are resolved here, inside the memo boundary.
+// They only run when pilotMarkerItemPropsEqual returns false.
+const PilotMarkerItem = React.memo(({pilot, myCid, friendCids, iconCacheVersion: _v, onPress}) => {
+ const role = getPilotMarkerRole(pilot, myCid, friendCids);
+ const entry = getMarkerImage(pilot.flight_plan?.aircraft || null, role);
+ const pilotImage = entry ? entry.image : (pilot.image || mapIcons.B737);
+ const pilotImageSize = entry ? entry.sizeDp : (pilot.image ? pilot.imageSize : defaultImageSize);
+
return isAndroid ? (
state.app.selectedClient);
const pilots = useSelector(state => state.vatsimLiveData.clients.pilots);
const myCid = useSelector(state => state.app.myCid);
const friendCids = useSelector(state => state.app.friendCids);
// Re-render when the icon cache is rebuilt (theme change) so role colors update
- useSelector(state => state.app.iconCacheVersion);
+ const iconCacheVersion = useSelector(selectIconCacheVersion);
const dispatch = useDispatch();
const selectedClientRef = useRef(selectedClient);
useEffect(() => {
selectedClientRef.current = selectedClient;
}, [selectedClient]);
+
const onPress = useCallback((pilot) => {
- if(selectedClientRef.current && pilot.callsign == selectedClientRef.current.callsign) {
+ if (selectedClientRef.current && pilot.callsign === selectedClientRef.current.callsign) {
dispatch(allActions.appActions.clientSelected(null));
} else {
markNewSelection();
@@ -85,37 +101,37 @@ const PilotMarkers = React.memo(function PilotMarkers({zoomLevel}) {
const zoomBand = getZoomBand(zoomLevel);
- return pilots
- .filter(pilot => {
+ const markers = useMemo(() => {
+ const filtered = pilots.filter(pilot => {
const groundspeed = Number(pilot.groundspeed);
const hasValidGroundspeed = Number.isFinite(groundspeed);
-
return (
zoomBand === 'airport' ||
pilot.callsign === selectedClient?.callsign ||
!hasValidGroundspeed ||
groundspeed > GROUND_SPEED_THRESHOLD
);
- })
- .map(pilot => {
- const role = getPilotMarkerRole(pilot, myCid, friendCids);
- const entry = getMarkerImage(pilot.flight_plan?.aircraft || null, role);
- const pilotImage = entry ? entry.image : (pilot.image || mapIcons.B737);
- const pilotImageSize = entry ? entry.sizeDp : (pilot.image ? pilot.imageSize : defaultImageSize);
+ });
+ return filtered.map(pilot => {
const markerKey = isAndroid
? `${pilot.key}_${coordKey(pilot.latitude, pilot.longitude)}`
: pilot.key;
- return ;
+ return (
+
+ );
});
+ }, [pilots, zoomBand, selectedClient?.callsign, myCid, friendCids, iconCacheVersion, onPress]);
+
+ return markers;
});
export default PilotMarkers;