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": "План реагування"
}