diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml index 72af27a2..8d53059e 100644 --- a/.github/workflows/react-native-cicd.yml +++ b/.github/workflows/react-native-cicd.yml @@ -197,6 +197,16 @@ jobs: node-version: '24' cache: 'yarn' + # Pin the JDK instead of taking the runner default: ubuntu-latest (26.04) defaults to JDK 25, + # where AGP's prefab step emits "A restricted method in java.lang.System has been called" and + # fails configureCMake (react-native-worklets). + - name: ☕ Setup Java + if: matrix.platform == 'android' + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: 🔎 Verify Xcode toolchain if: matrix.platform == 'ios' run: | diff --git a/src/__tests__/app/map.web.test.tsx b/src/__tests__/app/map.web.test.tsx index 7d3e31bf..6720fd56 100644 --- a/src/__tests__/app/map.web.test.tsx +++ b/src/__tests__/app/map.web.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations'; +import { MAP_SIGNALR_REFETCH_DELAY_MS } from '@/hooks/use-map-signalr-updates'; import { clearMapboxToken, useMapboxTokenStore } from '@/lib/mapbox-token'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; @@ -94,7 +95,7 @@ jest.mock('mapbox-gl', () => { jest.mock('@/stores/signalr/signalr-store', () => { const { create } = jest.requireActual('zustand'); - return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 })) }; }); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); @@ -102,6 +103,8 @@ jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })), getMapMarkerPopupOffset: jest.fn(() => ({ bottom: [0, -36] })), + // The real signature, so the test sees exactly which pin changes rebuild a marker. + getMapMarkerSignature: jest.requireActual('@/lib/map-markers-web').getMapMarkerSignature, MAP_MARKER_ANCHOR: 'bottom', })); jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); @@ -150,7 +153,7 @@ describe('Map screen (web) realtime locations', () => { mockMaps.length = 0; mockGetMapDataAndMarkers.mockReset(); mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); - useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 }); // Only the stylesheet injection touches document; report it as already present. (global as any).document = { getElementById: () => ({}) }; }); @@ -226,6 +229,32 @@ describe('Map screen (web) realtime locations', () => { expect(mockMarkers).toHaveLength(REST_PINS.length + 1); }); + it('refetches after an update hub change and rebuilds only the marker whose status pin changed, without moving the camera', async () => { + const RESPONDING_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/person-responding?v=1'; + const { createMapMarkerElement } = jest.requireMock('@/lib/map-markers-web') as { createMapMarkerElement: jest.Mock }; + await renderLoadedMap(); + const [personMarker] = liveMarker('pABC'); + const [unitMarker] = liveMarker('u12'); + + // The person went responding: same pin, same place, only the status pin (and its style) changed. + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS.map((pin) => (pin.Id === 'pABC' ? ({ ...pin, PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' } as MapMakerInfoData) : pin)))); + act(() => { + useSignalRStore.setState({ lastUpdateTimestamp: 1000 }); + }); + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS); + }); + await waitFor(() => expect(personMarker.remove).toHaveBeenCalled()); + + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2); + expect(createMapMarkerElement).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'pABC', PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' }), 'light'); + expect(liveMarker('pABC')).toHaveLength(1); + expect(liveMarker('u12')).toEqual([unitMarker]); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + it("keeps the user's POI layer toggles across a background refetch", async () => { await renderLoadedMap(); diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx index d6480d3f..48f98d8a 100644 --- a/src/app/(app)/map.web.tsx +++ b/src/app/(app)/map.web.tsx @@ -13,10 +13,11 @@ import { useActiveMapLayers } from '@/hooks/use-active-map-layers'; import { useAnalytics } from '@/hooks/use-analytics'; import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers'; import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; +import { useMapSignalRRefetch } from '@/hooks/use-map-signalr-updates'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { hasValidMapCoordinates } from '@/lib/map-markers'; -import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; +import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, getMapMarkerSignature, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; import { useDepartmentMapStyle } from '@/lib/map-style'; import { applyMapboxGlAccessToken } from '@/lib/mapbox-gl-token-web'; import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers'; @@ -217,7 +218,8 @@ export default function MapWeb() { ); // Fetches map data and markers: once on mount, and again in the background when the realtime location feed - // asks for it. Only the first successful load may move the camera; a refresh never touches the user's view. + // asks for it or the update hub reports a change. Only the first successful load may move the camera; a refresh + // never touches the user's view. const fetchAbortRef = useRef(null); const hasCenteredOnDataRef = useRef(false); const fetchMapData = useCallback(async () => { @@ -282,6 +284,10 @@ export default function MapWeb() { }; }, [fetchMapData]); + // Status and call changes from the update hub restyle pins (their pin images follow the status or priority), so they + // refetch the markers in the background as the native map does; fetchMapData only moves the camera on the first load. + useMapSignalRRefetch(requestPinsRefresh); + // Fetch map data and markers on mount useEffect(() => { void fetchMapData(); @@ -311,9 +317,9 @@ export default function MapWeb() { // Pins that did not change keep their object identity, so most markers are skipped outright. if (existing && meta && meta.pin === pin && meta.signature.startsWith(`${theme}:`)) return; - // Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon included) - // rebuilds the marker instead of keeping the old element. - const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}:${pin.IconUrl ?? ''}`; + // Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon, or the + // status/priority pin after a status change, included) rebuilds the marker instead of keeping the old element. + const signature = getMapMarkerSignature(pin, theme); if (existing && meta && meta.signature === signature) { if (meta.pin.Latitude !== pin.Latitude || meta.pin.Longitude !== pin.Longitude) { diff --git a/src/components/common/type-icon.tsx b/src/components/common/type-icon.tsx index d0500e16..dc8b95e4 100644 --- a/src/components/common/type-icon.tsx +++ b/src/components/common/type-icon.tsx @@ -17,6 +17,11 @@ export interface TypeIconProps { style?: StyleProp; /** Applied to the box around the rendered image (not the fallback), e.g. spacing that the fallback carries itself. */ containerStyle?: StyleProp; + /** + * How the image at `iconUrl` is fitted into the square: "contain" (the default) for an uploaded square icon, "cover" for + * a remote copy of a bundled pin that must sit exactly where the bundled image (drawn with resizeMode "cover") would. + */ + contentFit?: 'contain' | 'cover'; testID?: string; } @@ -26,7 +31,7 @@ export interface TypeIconProps { * icon the type picked, then `fallback` (nothing by default). An image that fails to load falls through to the next * option. Decorative: the name and type are always shown as text next to it, so it is hidden from screen readers. */ -export const TypeIcon: React.FC = React.memo(({ iconUrl, mapIcon, size = 24, fallback = null, style, containerStyle, testID }) => { +export const TypeIcon: React.FC = React.memo(({ iconUrl, mapIcon, size = 24, fallback = null, style, containerStyle, contentFit = 'contain', testID }) => { const [failedUrl, setFailedUrl] = useState(null); const [failedMapIcon, setFailedMapIcon] = useState(null); @@ -40,7 +45,7 @@ export const TypeIcon: React.FC = React.memo(({ iconUrl, mapIcon, { expect(screen.queryByTestId('uploaded-icon', hidden)).toBeNull(); }); }); + +describe('PinMarker status / priority pins', () => { + const PIN_ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1'; + const UPLOADED_ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/u12.png?s=responding&v=1'; + const unit = (patch: Partial = {}) => pin({ Id: 'u12', Title: 'Engine 41', Type: 1, ImagePath: 'Engine_Responding', ...patch }); + const remoteImages = () => screen.queryAllByTestId('uploaded-icon', hidden); + const bundledImages = () => screen.UNSAFE_queryAllByProps({ fadeDuration: 0 }); + + it("draws the status pin in the bundled pin's 32px box with the bundled pin's cover fit and no tint", () => { + render(); + + const [image] = remoteImages(); + expect(remoteImages()).toHaveLength(1); + expect(image.props.source).toEqual({ uri: PIN_ICON_URL }); + expect(image.props.contentFit).toBe('cover'); + expect(image.props.style).toEqual(expect.objectContaining({ width: 32, height: 32 })); + expect(image.props.style.tintColor).toBeUndefined(); + expect(image.props.tintColor).toBeUndefined(); + expect(bundledImages()).toHaveLength(0); + expect(screen.getByText('Engine 41')).toBeTruthy(); + }); + + it('falls back to the bundled pin when the status pin fails to load', () => { + render(); + + fireEvent(remoteImages()[0], 'error'); + + expect(remoteImages()).toHaveLength(0); + expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri); + }); + + it('prefers the uploaded icon, then the status pin, then the bundled pin as each fails to load', () => { + render(); + + expect(remoteImages()).toHaveLength(1); + expect(remoteImages()[0].props.source).toEqual({ uri: UPLOADED_ICON_URL }); + expect(remoteImages()[0].props.contentFit).toBe('contain'); + + fireEvent(remoteImages()[0], 'error'); + expect(remoteImages()).toHaveLength(1); + expect(remoteImages()[0].props.source).toEqual({ uri: PIN_ICON_URL }); + expect(remoteImages()[0].props.contentFit).toBe('cover'); + + fireEvent(remoteImages()[0], 'error'); + expect(remoteImages()).toHaveLength(0); + expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri); + }); + + it('draws the new status pin when the status changes, even after the previous one failed to load', () => { + const view = render(); + fireEvent(remoteImages()[0], 'error'); + expect(remoteImages()).toHaveLength(0); + + const ON_SCENE_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-onscene?v=1'; + view.rerender(); + + expect(remoteImages()[0].props.source).toEqual({ uri: ON_SCENE_PIN }); + }); + + it('keeps the bundled pin without a usable status pin', () => { + render(); + + expect(remoteImages()).toHaveLength(0); + expect(bundledImages()[0].props.source).toBe(MAP_ICONS.engine_responding.uri); + }); + + it('never draws a status pin on a POI marker', () => { + render(); + + expect(screen.getByTestId('poi-shape')).toBeTruthy(); + expect(remoteImages()).toHaveLength(0); + }); +}); diff --git a/src/components/maps/__tests__/unified-map-view.web.test.tsx b/src/components/maps/__tests__/unified-map-view.web.test.tsx index 0b762cda..5b4de459 100644 --- a/src/components/maps/__tests__/unified-map-view.web.test.tsx +++ b/src/components/maps/__tests__/unified-map-view.web.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations'; +import { MAP_SIGNALR_REFETCH_DELAY_MS } from '@/hooks/use-map-signalr-updates'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; @@ -94,7 +95,7 @@ jest.mock('mapbox-gl', () => { jest.mock('@/stores/signalr/signalr-store', () => { const { create } = jest.requireActual('zustand'); - return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) }; + return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 })) }; }); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() })); @@ -102,6 +103,8 @@ jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })), getMapMarkerPopupOffset: jest.fn(() => ({ bottom: [0, -36] })), + // The real signature, so the test sees exactly which pin changes rebuild a marker. + getMapMarkerSignature: jest.requireActual('@/lib/map-markers-web').getMapMarkerSignature, MAP_MARKER_ANCHOR: 'bottom', })); jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) })); @@ -132,7 +135,7 @@ describe('UnifiedMapView (web) realtime locations', () => { mockMaps.length = 0; mockGetMapDataAndMarkers.mockReset(); mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS)); - useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 }); // Only the stylesheet injection touches document; report it as already present. (global as any).document = { getElementById: () => ({}) }; }); @@ -215,6 +218,51 @@ describe('UnifiedMapView (web) realtime locations', () => { expect(markerAt(38, -118)).toBeDefined(); expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); }); + + it('refetches after an update hub change and rebuilds only the marker whose status pin changed, without moving the camera', async () => { + const RESPONDING_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1'; + const { createMapMarkerElement } = jest.requireMock('@/lib/map-markers-web') as { createMapMarkerElement: jest.Mock }; + const liveMarker = (pinId: string) => mockMarkers.filter((marker) => marker.pinId === pinId && marker.remove.mock.calls.length === 0); + await renderLoadedMap(); + const [unitMarker] = liveMarker('u12'); + const [personMarker] = liveMarker('pABC'); + + // The unit went responding: same pin, same place, only the status pin (and its style) changed. + mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS.map((pin) => (pin.Id === 'u12' ? ({ ...pin, PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' } as MapMakerInfoData) : pin)))); + act(() => { + useSignalRStore.setState({ lastUpdateTimestamp: 1000 }); + }); + act(() => { + useSignalRStore.setState({ lastUpdateTimestamp: 1001 }); + }); + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS); + }); + await waitFor(() => expect(unitMarker.remove).toHaveBeenCalled()); + + // One refetch for the burst of hub events. + expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2); + expect(createMapMarkerElement).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'u12', PinIconUrl: RESPONDING_PIN, IconStyle: 'responding' }), 'light', expect.any(Function)); + expect(liveMarker('u12')).toHaveLength(1); + expect(liveMarker('pABC')).toEqual([personMarker]); + expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1); + }); + + it('leaves the refresh of pins passed in by a parent to the parent', async () => { + render(, { createNodeMock: () => ({}) }); + act(() => mockMaps[0].handlers.load()); + + act(() => { + useSignalRStore.setState({ lastUpdateTimestamp: 2000 }); + }); + await act(async () => { + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS * 2); + }); + + expect(mockGetMapDataAndMarkers).not.toHaveBeenCalled(); + }); }); describe('UnifiedMapView (web) selected unit focus', () => { @@ -230,7 +278,7 @@ describe('UnifiedMapView (web) selected unit focus', () => { Object.assign(mockVisibleBox, { west: -180, south: -90, east: 180, north: 90 }); mockGetMapDataAndMarkers.mockReset(); mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(FOCUS_PINS)); - useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 }); + useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0, lastUpdateTimestamp: 0 }); (global as any).document = { getElementById: () => ({}) }; }); diff --git a/src/components/maps/dispatch-map-legend.tsx b/src/components/maps/dispatch-map-legend.tsx index 2ed92e27..522d8219 100644 --- a/src/components/maps/dispatch-map-legend.tsx +++ b/src/components/maps/dispatch-map-legend.tsx @@ -5,9 +5,10 @@ import { StyleSheet, Text, View } from 'react-native'; import { BOUNDARY_COLOR, type DispatchMapOverlay, PERSON_MARKER_COLOR, UNIT_MARKER_COLOR } from '@/lib/dispatch-board'; /** - * Key for the dispatch overlay on the location picker: units are the larger circles, personnel the smaller ones (each - * filled with its status colour), boundaries are the groups' geofences. Plain RN styles so it renders the same over the - * native and web maps. + * Key for the dispatch overlay on the location picker: available units are the larger circles, available personnel the + * smaller ones (each filled with its status colour; the overlay leaves unavailable resources off, so the counts are of + * who can be sent), boundaries are the groups' geofences. Plain RN styles so it renders the same over the native and web + * maps. */ export const DispatchMapLegend: React.FC<{ overlay: DispatchMapOverlay }> = ({ overlay }) => { const { t } = useTranslation(); diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index def39da3..37889ea3 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -140,7 +140,6 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca circleColor: ['get', 'color'], circleStrokeColor: '#ffffff', circleStrokeWidth: ['case', ['==', ['get', 'kind'], 'unit'], 2.5, 1.5], - circleOpacity: ['case', ['get', 'available'], 1, 0.5], }} /> (({ pin, size = 32, onPress }) => { const bundledIcon = ; - // Non-POI legacy marker rendering + // The built-in pin recoloured to the call's priority or the unit's / person's status: the same 32x37 image as the + // bundled pin, so it is drawn in the same box with the same fit and no tint, and falls back to the bundled pin when it + // is absent or fails to load. + const statusPin = ; + + // Non-POI legacy marker rendering: the uploaded icon, else the status/priority pin, else the bundled pin. return ( - + {pin.Title} @@ -146,6 +151,10 @@ const styles = StyleSheet.create({ overflow: 'visible', resizeMode: 'cover', }, + // The remote status/priority pin, fitted like `image` (its fit is the TypeIcon's contentFit). + pinImage: { + overflow: 'visible', + }, title: { marginTop: 2, overflow: 'visible', diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx index 5b53fb8d..b988562b 100644 --- a/src/components/maps/unified-map-view.web.tsx +++ b/src/components/maps/unified-map-view.web.tsx @@ -6,10 +6,11 @@ import { StyleSheet, View } from 'react-native'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { useMapLiveLocations } from '@/hooks/use-map-live-locations'; +import { useMapSignalRRefetch } from '@/hooks/use-map-signalr-updates'; import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { hasValidMapCoordinates } from '@/lib/map-markers'; -import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; +import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, getMapMarkerSignature, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; import { useDepartmentMapStyle } from '@/lib/map-style'; import { applyMapboxGlAccessToken } from '@/lib/mapbox-gl-token-web'; import { boundsForFocusPoints, FOCUS_MAX_ZOOM, focusFrameKey, hasPointLeftView, type MapFocus, resolveMapFocusPoints } from '@/lib/unit-map-focus'; @@ -212,7 +213,8 @@ const UnifiedMapViewComponent: React.FC = ({ }, [mapStyle, isMapReady]); // Fetches the pins this view shows when auto-fetching: once on mount, and again in the background when the - // realtime location feed asks for it. Only the first successful load may move the camera. + // realtime location feed asks for it or the update hub reports a change. Only the first successful load may move + // the camera. const fetchAbortRef = useRef(null); const hasCenteredOnDataRef = useRef(false); const fetchPins = useCallback(async () => { @@ -291,6 +293,11 @@ const UnifiedMapViewComponent: React.FC = ({ : null; }, [autoFetchPins, fetchPins]); + // Status and call changes from the update hub restyle pins (their pin images follow the status or priority), so the + // pins this view fetched itself are refetched in the background as on native; fetchPins only moves the camera on the + // first load. A parent that passes `pins` owns their refresh. + useMapSignalRRefetch(requestPinsRefresh, autoFetchPins && externalPins === undefined); + // Auto-fetch pins if enabled useEffect(() => { if (!autoFetchPins) return; @@ -326,9 +333,9 @@ const UnifiedMapViewComponent: React.FC = ({ // instead of re-deriving its signature on every realtime update. if (existing && currentMeta && currentMeta.pin === pin && currentMeta.signature.startsWith(`${theme}:`)) return; - // Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon included) - // rebuilds the marker instead of keeping the old element. - const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}:${pin.IconUrl ?? ''}`; + // Everything the marker element or its popup is built from, so a change to any of it (an uploaded icon, or the + // status/priority pin after a status change, included) rebuilds the marker instead of keeping the old element. + const signature = getMapMarkerSignature(pin, theme); if (existing) { const meta = currentMeta; diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts index 01c15dcd..57520769 100644 --- a/src/hooks/__tests__/use-map-signalr-updates.test.ts +++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts @@ -6,7 +6,7 @@ import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersD import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; -import { useMapSignalRUpdates } from '../use-map-signalr-updates'; +import { MAP_SIGNALR_REFETCH_DELAY_MS, useMapSignalRRefetch, useMapSignalRUpdates } from '../use-map-signalr-updates'; // Mock dependencies jest.mock('@/api/mapping/mapping'); @@ -545,3 +545,71 @@ describe('useMapSignalRUpdates', () => { expect(mockOnMarkersUpdate.mock.calls[0][1]).toBeGreaterThanOrEqual(startedNoEarlierThan); }); }); + +describe('useMapSignalRRefetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + const renderRefetch = (refetch: () => void, initialTimestamp: number, enabled = true) => { + mockUseSignalRStore.mockReturnValue(initialTimestamp); + return renderHook(({ callback, isEnabled }: { callback: () => void; isEnabled: boolean }) => useMapSignalRRefetch(callback, isEnabled), { initialProps: { callback: refetch, isEnabled: enabled } }); + }; + + it('skips the change recorded when the map mounted (its own first load covers it) and refetches once, debounced, for later ones', () => { + const refetch = jest.fn(); + const { rerender } = renderRefetch(refetch, 500); + + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS * 2); + expect(refetch).not.toHaveBeenCalled(); + + // A burst of hub events (a status change, then a call update) costs one refetch. + mockUseSignalRStore.mockReturnValue(600); + rerender({ callback: refetch, isEnabled: true }); + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS / 2); + mockUseSignalRStore.mockReturnValue(700); + rerender({ callback: refetch, isEnabled: true }); + expect(refetch).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS); + expect(refetch).toHaveBeenCalledTimes(1); + // It refetches through the map's own fetch, not one of its own. + expect(mockGetMapDataAndMarkers).not.toHaveBeenCalled(); + }); + + it('does not refetch while disabled (a map whose parent owns the pins)', () => { + const refetch = jest.fn(); + const { rerender } = renderRefetch(refetch, 0, false); + + mockUseSignalRStore.mockReturnValue(900); + rerender({ callback: refetch, isEnabled: false }); + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS * 2); + + expect(refetch).not.toHaveBeenCalled(); + }); + + it('keeps a pending refetch when the callback identity changes, and runs the latest callback', () => { + const first = jest.fn(); + const latest = jest.fn(); + const { rerender } = renderRefetch(first, 0); + + mockUseSignalRStore.mockReturnValue(1000); + rerender({ callback: first, isEnabled: true }); + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS / 2); + rerender({ callback: latest, isEnabled: true }); + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS / 2); + + expect(first).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); + + // The same change is not refetched again. + rerender({ callback: latest, isEnabled: true }); + jest.advanceTimersByTime(MAP_SIGNALR_REFETCH_DELAY_MS * 2); + expect(latest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/use-dispatch-map-overlay.ts b/src/hooks/use-dispatch-map-overlay.ts index 81460f58..5c2f4b12 100644 --- a/src/hooks/use-dispatch-map-overlay.ts +++ b/src/hooks/use-dispatch-map-overlay.ts @@ -4,9 +4,10 @@ import { buildDispatchMapOverlay, type DispatchMapOverlay } from '@/lib/dispatch import { boardKeyFor, useDispatchStore } from '@/stores/dispatch/store'; /** - * What to draw on a call's location map while dispatching: the units and personnel the nearest-unit board placed for - * that location, and every group boundary. Loads the board (shared with the recipient picker, so both rank alike) and - * the dispatch lists once. Null until there is something to draw. + * What to draw on a call's location map while dispatching: the available units and personnel the nearest-unit board + * placed for that location (unavailable ones stay in the recipient picker, with their status, but not on the map), and + * every group boundary. Loads the board (shared with the recipient picker, so both rank alike) and the dispatch lists + * once. Null until there is something to draw. */ export const useDispatchMapOverlay = (location?: { latitude: number; longitude: number } | null): DispatchMapOverlay | null => { const board = useDispatchStore((state) => state.board); diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts index fb16ae66..7717d6a2 100644 --- a/src/hooks/use-map-signalr-updates.ts +++ b/src/hooks/use-map-signalr-updates.ts @@ -187,3 +187,39 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData return { requestRefresh }; }; + +/** How long the update hub must stay quiet before `useMapSignalRRefetch` refetches. */ +export const MAP_SIGNALR_REFETCH_DELAY_MS = DEBOUNCE_DELAY; + +/** + * Runs `refetch` (debounced) whenever the update hub reports a change (a unit or person status, a call added, updated + * or closed) after the map mounted, for a map that already owns its marker fetch. Status and priority changes reach the + * pins only through such a refetch: the pin images (`PinIconUrl`, `IconUrl`) change with them. `refetch` must be a + * background refresh that leaves the camera where the user put it; the change already recorded when the map mounted is + * covered by the map's own first load and is skipped. + */ +export const useMapSignalRRefetch = (refetch: () => void, enabled: boolean = true) => { + const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp); + const handledTimestampRef = useRef(lastUpdateTimestamp); + + // The latest refetch, so a new callback identity neither cancels a pending refresh nor schedules an extra one. + const refetchRef = useRef(refetch); + useEffect(() => { + refetchRef.current = refetch; + }, [refetch]); + + useEffect(() => { + if (!enabled || !(lastUpdateTimestamp > 0) || lastUpdateTimestamp === handledTimestampRef.current) return; + + const timer = setTimeout(() => { + handledTimestampRef.current = lastUpdateTimestamp; + logger.debug({ + message: 'Refetching map markers after an update hub change', + context: { timestamp: lastUpdateTimestamp }, + }); + refetchRef.current(); + }, DEBOUNCE_DELAY); + + return () => clearTimeout(timer); + }, [enabled, lastUpdateTimestamp]); +}; diff --git a/src/lib/__tests__/dispatch-board.test.ts b/src/lib/__tests__/dispatch-board.test.ts index dd4add89..c0dc1b84 100644 --- a/src/lib/__tests__/dispatch-board.test.ts +++ b/src/lib/__tests__/dispatch-board.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { boundsAround, buildDispatchMapOverlay, callLocationOf, formatAge, formatDistanceEta, orderRecipients, parseGeofenceRing, positionNote, rankBoardUnits } from '@/lib/dispatch-board'; -import { EtaSource, type NearestUnitBoard, type NearestUnitResult, UnitPositionSource } from '@/models/v4/dispatch/nearestUnitBoard'; +import { EtaSource, type NearestPersonnelResult, type NearestUnitBoard, type NearestUnitResult, UnitPositionSource } from '@/models/v4/dispatch/nearestUnitBoard'; const unit = (overrides: Partial): NearestUnitResult => ({ UnitId: 1, @@ -118,7 +118,7 @@ describe('dispatch board helpers', () => { expect(overlay).not.toBeNull(); expect(overlay!.unitCount).toBe(1); expect(overlay!.personCount).toBe(1); - expect(overlay!.points.features[0].properties).toMatchObject({ id: 'u1', kind: 'unit', color: '#2f9e44', available: true }); + expect(overlay!.points.features[0].properties).toMatchObject({ id: 'u1', kind: 'unit', color: '#2f9e44' }); expect(overlay!.points.features[0].properties.detail).toBe('Available · 1.4 km'); expect(overlay!.boundaries.features[0].properties.color).toBe('#0ea5e9'); expect(overlay!.nearest).toEqual([ @@ -141,6 +141,47 @@ describe('dispatch board helpers', () => { expect(overlay!.nearest).toEqual([[-119.79, 39.51]]); }); + it('maps only the available units and personnel, while the picker still ranks everyone', () => { + const person = (overrides: Partial): NearestPersonnelResult => ({ + UserId: 'u1', + Name: 'Person', + IsAvailable: true, + IsOnShift: true, + Roles: [], + Latitude: 39.52, + Longitude: -119.78, + LocationIsStale: false, + LocationHidden: false, + DistanceMeters: 2000, + EtaSource: EtaSource.Estimated, + ...overrides, + }); + const nearestBoard: NearestUnitBoard = { + // The closest unit and person are busy: they must not be drawn, counted or used to frame the call. + ...board([ + unit({ UnitId: 1, Name: 'Busy', IsAvailable: false, Latitude: 39.5, Longitude: -119.8, DistanceMeters: 100 }), + unit({ UnitId: 2, Name: 'Free', Latitude: 39.51, Longitude: -119.79, DistanceMeters: 1400 }), + ]), + Personnel: [person({ UserId: 'busy', IsAvailable: false, Latitude: 39.501, Longitude: -119.801, DistanceMeters: 150 }), person({ UserId: 'free' })], + }; + + const overlay = buildDispatchMapOverlay(nearestBoard, []); + + expect(overlay!.points.features.map((feature) => feature.properties.id)).toEqual(['u2', 'pfree']); + expect(overlay!.unitCount).toBe(1); + expect(overlay!.personCount).toBe(1); + expect(overlay!.nearest).toEqual([ + [-119.79, 39.51], + [-119.78, 39.52], + ]); + // The recipient picker keeps the busy unit, with its status, for reassignment. + expect(rankBoardUnits(nearestBoard).get('1')).toMatchObject({ index: 0, nearestRank: null }); + }); + + it('has nothing to draw when nobody placed is available and there are no boundaries', () => { + expect(buildDispatchMapOverlay(board([unit({ IsAvailable: false, Latitude: 39.51, Longitude: -119.79, DistanceMeters: 1400 })]), [])).toBeNull(); + }); + it('has nothing to draw without positions or boundaries', () => { expect(buildDispatchMapOverlay(null, [])).toBeNull(); }); diff --git a/src/lib/__tests__/map-markers-web.test.ts b/src/lib/__tests__/map-markers-web.test.ts index fb4b79f5..f981f909 100644 --- a/src/lib/__tests__/map-markers-web.test.ts +++ b/src/lib/__tests__/map-markers-web.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; +import { buildMapPinPopupHtml, createMapMarkerElement, getMapMarkerPopupOffset, getMapMarkerSignature, MAP_MARKER_ANCHOR } from '@/lib/map-markers-web'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; const pin = (patch: Partial = {}): MapMakerInfoData => @@ -117,6 +117,74 @@ describe('createMapMarkerElement type icons', () => { }); }); +describe('createMapMarkerElement status / priority pins', () => { + const ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/u12.png?s=responding&v=1'; + const PIN_ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1'; + const iconSrc = (el: HTMLElement) => el.querySelector('img')?.getAttribute('src'); + const failImage = (el: HTMLElement) => el.querySelector('img')?.dispatchEvent(new Event('error')); + + it('draws the status pin where the bundled pin goes, stepping down to the bundled pin and then the flag when loads fail', () => { + const el = createMapMarkerElement(pin({ Type: 1, ImagePath: 'Engine_Responding', PinIconUrl: PIN_ICON_URL, IconStyle: 'responding' })); + const img = el.querySelector('img')!; + + expect(iconSrc(el)).toBe(PIN_ICON_URL); + // Same box, fit and bottom anchor as the bundled pin, with no tint. + expect(img.style.width).toBe('32px'); + expect(img.style.height).toBe('32px'); + expect(img.style.objectFit).toBe('contain'); + expect(img.style.objectPosition).toBe('center bottom'); + expect(img.style.filter).toBe(''); + + failImage(el); + expect(iconSrc(el)).toBe('/assets/mapping/engine_responding.png'); + failImage(el); + expect(iconSrc(el)).toBe('/assets/mapping/flag.png'); + }); + + it('prefers the uploaded icon over the status pin, and the status pin over the bundled pin when the upload fails', () => { + const el = createMapMarkerElement(pin({ Type: 0, ImagePath: 'Call', IconUrl: ICON_URL, PinIconUrl: PIN_ICON_URL, IconStyle: 'high' })); + + expect(iconSrc(el)).toBe(ICON_URL); + failImage(el); + expect(iconSrc(el)).toBe(PIN_ICON_URL); + failImage(el); + expect(iconSrc(el)).toBe('/assets/mapping/call.png'); + }); + + it('keeps the bundled pin without a usable status pin (older servers, stations, or a non-http URL)', () => { + expect(iconSrc(createMapMarkerElement(pin({ Type: 3, ImagePath: 'Person_RespondingCall', PinIconUrl: null })))).toBe('/assets/mapping/person_respondingcall.png'); + expect(iconSrc(createMapMarkerElement(pin({ Type: 3, ImagePath: 'Person_RespondingCall', PinIconUrl: '/api/v4/TypeIcons/Pins/person-responding' })))).toBe('/assets/mapping/person_respondingcall.png'); + expect(iconSrc(createMapMarkerElement(pin({ Type: 2, ImagePath: 'Station' })))).toBe('/assets/mapping/station.png'); + }); + + it('never draws a status pin on a POI marker', () => { + const el = createMapMarkerElement(pin({ Type: 4, PoiTypeId: 5, PoiImage: 'map-icon-hospital', Marker: 'MAP_PIN', PinIconUrl: PIN_ICON_URL })); + + expect(el.querySelector('img')).toBeNull(); + expect(el.querySelector('.rg-map__poi-marker')).not.toBeNull(); + }); +}); + +describe('getMapMarkerSignature', () => { + const base = pin({ Type: 1, ImagePath: 'Engine_Available', PinIconUrl: 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-available?v=1', IconStyle: 'available' }); + + it('changes when only the status pin or its style changes, so the marker is rebuilt', () => { + const signature = getMapMarkerSignature(base, 'light'); + + expect(getMapMarkerSignature({ ...base, PinIconUrl: 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1' } as MapMakerInfoData, 'light')).not.toBe(signature); + expect(getMapMarkerSignature({ ...base, IconStyle: 'responding' } as MapMakerInfoData, 'light')).not.toBe(signature); + expect(getMapMarkerSignature({ ...base, IconUrl: 'https://api.resgrid.dev/api/v4/TypeIcons/u12.png?s=available&v=1' } as MapMakerInfoData, 'light')).not.toBe(signature); + }); + + it('ignores a move, which repositions the marker in place, and is prefixed with the theme', () => { + const signature = getMapMarkerSignature(base, 'dark'); + + expect(getMapMarkerSignature({ ...base, Latitude: 40, Longitude: -120 } as MapMakerInfoData, 'dark')).toBe(signature); + expect(signature.startsWith('dark:')).toBe(true); + expect(getMapMarkerSignature(base, 'light')).not.toBe(signature); + }); +}); + describe('createMapMarkerElement placement', () => { const ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/u12.png'; // jsdom does no layout, so the marker's box is read from its children's styles: only in-flow children size it. diff --git a/src/lib/dispatch-board.ts b/src/lib/dispatch-board.ts index df8d9cf7..2043ffb6 100644 --- a/src/lib/dispatch-board.ts +++ b/src/lib/dispatch-board.ts @@ -4,10 +4,10 @@ import { EtaSource, type NearestPersonnelResult, type NearestUnitBoard, type Nea import { type DispatchItem } from '@/stores/dispatch/store'; /* - * The nearest-unit board (/Dispatch/GetNearestUnits) joined onto the dispatch picker's rows and drawn on the New Call - * map. The board ranks every unit and responder the viewer may dispatch for the call location: available first, then - * response time, then distance. Rows the board does not list (out of the viewer's dispatch scope, or no call location - * yet) keep their place after the ranked ones, by name. + * The nearest-unit board (/Dispatch/GetNearestUnits) joined onto the dispatch picker's rows, and its available entries + * drawn on the New Call / Edit Call map. The board ranks every unit and responder the viewer may dispatch for the call + * location: available first, then response time, then distance. Rows the board does not list (out of the viewer's + * dispatch scope, or no call location yet) keep their place after the ranked ones, by name. */ /** How many of the closest available units or personnel get a "Nearest" badge. */ @@ -193,7 +193,6 @@ export interface DispatchMapPointProperties { color: string; /** Status, distance and ETA, for the popup. */ detail: string; - available: boolean; } export interface DispatchMapBoundaryProperties { @@ -207,7 +206,9 @@ export interface DispatchMapOverlay { boundaries: GeoJSON.FeatureCollection; /** [lng, lat] of the closest few resources with a position, to frame the call with them. */ nearest: [number, number][]; + /** Available units on the map. */ unitCount: number; + /** Available personnel on the map. */ personCount: number; } @@ -221,15 +222,19 @@ const usable = (lat: number | null | undefined, lng: number | null | undefined): typeof lat === 'number' && typeof lng === 'number' && Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0); /** - * The units and responders the board placed (hidden positions carry no coordinates and are left off), plus every - * group boundary, as GeoJSON for the location picker. Null when there is nothing to draw. + * The available units and responders the board placed (hidden positions carry no coordinates and are left off), plus + * every group boundary, as GeoJSON for the location picker. Null when there is nothing to draw. + * + * The call maps (New Call, Edit Call) show only who can be sent: a unit or person the board marks unavailable is left + * off, by the same `IsAvailable` answer the recipient picker uses for the rows the board lists (`isDispatchUnitAvailable` + * / `isDispatchUserAvailable`). The picker itself still lists everyone with their status, for reassignment. */ export const buildDispatchMapOverlay = (board: NearestUnitBoard | null | undefined, groups: { Id: string; Name: string; Geofence?: string | null; GeofenceColor?: string | null }[]): DispatchMapOverlay | null => { const points: GeoJSON.Feature[] = []; const nearest: [number, number][] = []; for (const unit of board?.Units ?? []) { - if (!usable(unit.Latitude, unit.Longitude)) continue; + if (!unit.IsAvailable || !usable(unit.Latitude, unit.Longitude)) continue; const coordinates: [number, number] = [unit.Longitude as number, unit.Latitude as number]; points.push({ type: 'Feature', @@ -240,15 +245,14 @@ export const buildDispatchMapOverlay = (board: NearestUnitBoard | null | undefin name: unit.Name, color: hexOr(unit.StatusColor, UNIT_MARKER_COLOR), detail: [unit.StatusText, formatDistanceEta(unit.DistanceMeters, unit.EtaSeconds, unit.EtaSource)].filter(Boolean).join(' · '), - available: unit.IsAvailable, }, }); - if (unit.IsAvailable && withinFrame(unit.DistanceMeters) && nearest.length < NEAREST_BADGE_COUNT) nearest.push(coordinates); + if (withinFrame(unit.DistanceMeters) && nearest.length < NEAREST_BADGE_COUNT) nearest.push(coordinates); } let nearestPeople = 0; for (const person of board?.Personnel ?? []) { - if (!usable(person.Latitude, person.Longitude)) continue; + if (!person.IsAvailable || !usable(person.Latitude, person.Longitude)) continue; const coordinates: [number, number] = [person.Longitude as number, person.Latitude as number]; points.push({ type: 'Feature', @@ -259,10 +263,9 @@ export const buildDispatchMapOverlay = (board: NearestUnitBoard | null | undefin name: person.Name, color: hexOr(person.StatusColor, PERSON_MARKER_COLOR), detail: [person.StatusText, formatDistanceEta(person.DistanceMeters, person.EtaSeconds, person.EtaSource)].filter(Boolean).join(' · '), - available: person.IsAvailable, }, }); - if (person.IsAvailable && withinFrame(person.DistanceMeters) && nearestPeople < NEAREST_BADGE_COUNT) { + if (withinFrame(person.DistanceMeters) && nearestPeople < NEAREST_BADGE_COUNT) { nearest.push(coordinates); nearestPeople++; } diff --git a/src/lib/dispatch-map-layers-web.ts b/src/lib/dispatch-map-layers-web.ts index 6c7d8cd7..cb58ee7a 100644 --- a/src/lib/dispatch-map-layers-web.ts +++ b/src/lib/dispatch-map-layers-web.ts @@ -3,9 +3,9 @@ import type mapboxgl from 'mapbox-gl'; import { type DispatchMapOverlay } from '@/lib/dispatch-board'; /* - * The dispatch overlay (units, personnel, group boundaries) as Mapbox GL sources and layers, for the web location - * picker. Sources live in the style, so a style switch (day/night) drops them: call `applyDispatchOverlay` again on - * 'style.load'. + * The dispatch overlay (available units and personnel, group boundaries) as Mapbox GL sources and layers, for the web + * location picker. Sources live in the style, so a style switch (day/night) drops them: call `applyDispatchOverlay` + * again on 'style.load'. */ export const DISPATCH_POINTS_SOURCE = 'dispatch-resources'; @@ -37,9 +37,7 @@ const ensureLayers = (map: mapboxgl.Map) => { 'circle-color': ['get', 'color'], 'circle-stroke-color': '#ffffff', 'circle-stroke-width': ['case', isUnit, 2.5, 1.5], - // Unavailable resources stay on the map, fainter. - 'circle-opacity': ['case', ['get', 'available'], 1, 0.5], - 'circle-stroke-opacity': ['case', ['get', 'available'], 1, 0.5], + // Only available resources are in the overlay (buildDispatchMapOverlay leaves the rest off), so all draw solid. }, }); map.addLayer({ diff --git a/src/lib/map-markers-web.ts b/src/lib/map-markers-web.ts index 5e5f21f0..c11953e2 100644 --- a/src/lib/map-markers-web.ts +++ b/src/lib/map-markers-web.ts @@ -190,6 +190,15 @@ export const getMapMarkerPopupOffset = (pin: MapMakerInfoData): Record + `${colorScheme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}:${pin.IconUrl ?? ''}:${pin.PinIconUrl ?? ''}:${pin.IconStyle ?? ''}`; + /** * Creates a marker DOM element that can be used with Mapbox GL JS. * Handles both POI markers (SVG shape + map-icons font) and @@ -245,13 +254,16 @@ export const createMapMarkerElement = (pin: MapMakerInfoData, colorScheme: 'dark const iconKey = resolveMapMarkerIconKey(pin) as MapIconKey; const iconData = MAP_ICONS[iconKey] || MAP_ICONS['flag']; const img = document.createElement('img'); - // The uploaded type icon (when there is one) shows first; each failed load steps down to the bundled icon, then - // the flag, and stops there rather than retrying a broken image forever. - const sources = [uploadedIconUrl, getMapIconWebUrl(iconData), getMapIconWebUrl(MAP_ICONS['flag'])].filter((src, index, all): src is string => !!src && all.indexOf(src) === index); + // The built-in pin recoloured to the call's priority or the unit's / person's status: the same 32x37 image as the + // bundled pin, so it takes the bundled pin's place in the same box and fit, with no tint. + const statusPinUrl = getTypeIconUrl(pin.PinIconUrl); + // The uploaded type icon (when there is one) shows first, then the status/priority pin; each failed load steps down + // to the bundled icon, then the flag, and stops there rather than retrying a broken image forever. + const sources = [uploadedIconUrl, statusPinUrl, getMapIconWebUrl(iconData), getMapIconWebUrl(MAP_ICONS['flag'])].filter((src, index, all): src is string => !!src && all.indexOf(src) === index); let sourceIndex = 0; img.src = sources[sourceIndex]; - // Fitted, not cropped: a bundled 32x37 pin scales into the box with its tip on the box's bottom edge, and an - // uploaded square icon fills it. + // Fitted, not cropped: a bundled 32x37 pin (or its status/priority copy) scales into the box with its tip on the + // box's bottom edge, and an uploaded square icon fills it. img.style.width = `${PIN_ICON_SIZE}px`; img.style.height = `${PIN_ICON_SIZE}px`; img.style.objectFit = 'contain'; diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts index 088fced4..ffb1efee 100644 --- a/src/models/v4/mapping/getMapDataAndMarkersData.ts +++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts @@ -33,4 +33,16 @@ export class MapMakerInfoData { * otherwise or from older servers. */ public IconUrl?: string | null = null; + /** + * The built-in pin recoloured to the call's priority or the unit's / person's status (absolute URL of a 32x37 PNG with + * the same shape, size and bottom-tip anchor as the bundled pins). Drawn where and how the bundled `ImagePath` pin is + * drawn when there is no `IconUrl`; null when the priority/status has no meaning, for stations, POIs and hydrants, and + * from older servers. + */ + public PinIconUrl?: string | null = null; + /** + * The style key `PinIconUrl` was drawn in: a status (`available`, `dispatched`, `responding`, `onscene`, `transporting`, + * `returning`, `staging`, `delayed`, `unavailable`) or a priority (`low`, `medium`, `high`, `emergency`). Null as above. + */ + public IconStyle?: string | null = null; } diff --git a/src/translations/ar.json b/src/translations/ar.json index a1f394f5..a293d68c 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -425,8 +425,8 @@ "crew": "الطاقم: {{names}}", "no_crew": "لا يوجد طاقم معيّن", "on_unit": "على {{unit}}", - "map_units": "الوحدات", - "map_personnel": "الأفراد", + "map_units": "الوحدات المتاحة", + "map_personnel": "الأفراد المتاحون", "map_boundaries": "حدود المحطات", "run_card_pick": "خطة الاستجابة" } diff --git a/src/translations/de.json b/src/translations/de.json index a5324b9f..9a3d6a55 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -425,8 +425,8 @@ "crew": "Besatzung: {{names}}", "no_crew": "Keine Besatzung zugewiesen", "on_unit": "Auf {{unit}}", - "map_units": "Einheiten", - "map_personnel": "Personal", + "map_units": "Verfügbare Einheiten", + "map_personnel": "Verfügbares Personal", "map_boundaries": "Wachbereiche", "run_card_pick": "Alarmierungsplan" } diff --git a/src/translations/el.json b/src/translations/el.json index eea2f57b..74f72751 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -425,8 +425,8 @@ "crew": "Πλήρωμα: {{names}}", "no_crew": "Δεν έχει οριστεί πλήρωμα", "on_unit": "Στο {{unit}}", - "map_units": "Μονάδες", - "map_personnel": "Προσωπικό", + "map_units": "Διαθέσιμες μονάδες", + "map_personnel": "Διαθέσιμο προσωπικό", "map_boundaries": "Όρια σταθμών", "run_card_pick": "Σχέδιο απόκρισης" } diff --git a/src/translations/en.json b/src/translations/en.json index 79e37505..dc351fe4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -425,8 +425,8 @@ "crew": "Crew: {{names}}", "no_crew": "No crew assigned", "on_unit": "On {{unit}}", - "map_units": "Units", - "map_personnel": "Personnel", + "map_units": "Available units", + "map_personnel": "Available personnel", "map_boundaries": "Station boundaries", "run_card_pick": "Run card" } diff --git a/src/translations/es.json b/src/translations/es.json index 72a6011b..89a26ed5 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -425,8 +425,8 @@ "crew": "Dotación: {{names}}", "no_crew": "Sin dotación asignada", "on_unit": "En {{unit}}", - "map_units": "Unidades", - "map_personnel": "Personal", + "map_units": "Unidades disponibles", + "map_personnel": "Personal disponible", "map_boundaries": "Límites de estaciones", "run_card_pick": "Plan de respuesta" } diff --git a/src/translations/fr.json b/src/translations/fr.json index e80de3ef..b3bf97f5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -425,8 +425,8 @@ "crew": "Équipage : {{names}}", "no_crew": "Aucun équipage affecté", "on_unit": "Sur {{unit}}", - "map_units": "Unités", - "map_personnel": "Personnel", + "map_units": "Unités disponibles", + "map_personnel": "Personnel disponible", "map_boundaries": "Secteurs des casernes", "run_card_pick": "Plan de réponse" } diff --git a/src/translations/it.json b/src/translations/it.json index f85ade53..19b16732 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -425,8 +425,8 @@ "crew": "Equipaggio: {{names}}", "no_crew": "Nessun equipaggio assegnato", "on_unit": "Su {{unit}}", - "map_units": "Unità", - "map_personnel": "Personale", + "map_units": "Unità disponibili", + "map_personnel": "Personale disponibile", "map_boundaries": "Confini delle sedi", "run_card_pick": "Piano di risposta" } diff --git a/src/translations/pl.json b/src/translations/pl.json index 17c688a2..5aa7de85 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -425,8 +425,8 @@ "crew": "Załoga: {{names}}", "no_crew": "Brak przypisanej załogi", "on_unit": "Na {{unit}}", - "map_units": "Jednostki", - "map_personnel": "Personel", + "map_units": "Dostępne jednostki", + "map_personnel": "Dostępny personel", "map_boundaries": "Granice rejonów", "run_card_pick": "Plan zadysponowania" } diff --git a/src/translations/sv.json b/src/translations/sv.json index c5b16ff3..dbb5b878 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -425,8 +425,8 @@ "crew": "Besättning: {{names}}", "no_crew": "Ingen besättning tilldelad", "on_unit": "På {{unit}}", - "map_units": "Enheter", - "map_personnel": "Personal", + "map_units": "Tillgängliga enheter", + "map_personnel": "Tillgänglig personal", "map_boundaries": "Stationsområden", "run_card_pick": "Insatsplan" } diff --git a/src/translations/uk.json b/src/translations/uk.json index 8adb710e..ba198628 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -425,8 +425,8 @@ "crew": "Екіпаж: {{names}}", "no_crew": "Екіпаж не призначено", "on_unit": "На {{unit}}", - "map_units": "Підрозділи", - "map_personnel": "Персонал", + "map_units": "Доступні підрозділи", + "map_personnel": "Доступний персонал", "map_boundaries": "Межі станцій", "run_card_pick": "План реагування" }