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;