diff --git a/.github/workflows/react-native-cicd.yml b/.github/workflows/react-native-cicd.yml
index ab78b64..bd44ef4 100644
--- a/.github/workflows/react-native-cicd.yml
+++ b/.github/workflows/react-native-cicd.yml
@@ -146,12 +146,34 @@ jobs:
sudo apt-get clean || true
sudo rm -rf /var/lib/apt/lists/*
- # Remove swap
+ # Replace the image's swap with a larger one. The native (CMake/clang) and Kotlin
+ # compiles run alongside 4 GB Gradle and Node heaps; with no swap the runner starves
+ # and GitHub drops it ("hosted runner lost communication"). Prefer the /mnt temp disk
+ # so swap doesn't eat into the root disk freed above.
sudo swapoff -a || true
- sudo rm -f /swapfile || true
+ sudo rm -f /swapfile /mnt/swapfile || true
+ SWAP_FILE=/swapfile
+ if mountpoint -q /mnt; then SWAP_FILE=/mnt/swapfile; fi
+ if sudo fallocate -l 8G "$SWAP_FILE" && sudo chmod 600 "$SWAP_FILE" && sudo mkswap "$SWAP_FILE" && sudo swapon "$SWAP_FILE"; then
+ echo "Enabled 8G swap at $SWAP_FILE"
+ else
+ echo "::warning::Could not enable swap at $SWAP_FILE"
+ fi
echo "Disk space after cleanup:"
df -h
+ free -h
+
+ # ubuntu-26.04 mounts /tmp as an ~8 GB RAM-backed tmpfs, and eas build --local works
+ # under /tmp by default, so the Gradle/CMake output fills it ("Disk quota exceeded").
+ # Move EAS's working dir and the general temp dir onto the runner's disk.
+ - name: 📂 Move build temp dirs off tmpfs (Android)
+ if: matrix.platform == 'android'
+ run: |
+ mkdir -p "${RUNNER_TEMP}/tmp" "${RUNNER_TEMP}/eas-build"
+ echo "TMPDIR=${RUNNER_TEMP}/tmp" >> "$GITHUB_ENV"
+ echo "EAS_LOCAL_BUILD_WORKINGDIR=${RUNNER_TEMP}/eas-build" >> "$GITHUB_ENV"
+ df -h /tmp "${RUNNER_TEMP}"
- name: 🏗 Setup Node.js
uses: actions/setup-node@v4
@@ -159,6 +181,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: Setup Expo
uses: expo/expo-github-action@v8
with:
@@ -248,6 +280,9 @@ jobs:
run: |
mkdir -p ~/.gradle/
echo ${GRADLE_PROPERTIES} > ~/.gradle/gradle.properties
+ # Each Gradle worker can drive its own CMake/ninja native build, so cap workers to
+ # keep concurrent clang processes within the runner's memory.
+ echo "org.gradle.workers.max=2" >> ~/.gradle/gradle.properties
- name: 📱 Build Development APK
if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'dev'))
diff --git a/src/__tests__/command-map-legend.test.tsx b/src/__tests__/command-map-legend.test.tsx
new file mode 100644
index 0000000..d756ff3
--- /dev/null
+++ b/src/__tests__/command-map-legend.test.tsx
@@ -0,0 +1,119 @@
+// Incident tactical map screen (src/app/command-map/[callId].tsx): the map legend control and sheet.
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import CommandMapScreen from '@/app/command-map/[callId]';
+
+jest.mock('expo-router', () => ({
+ Stack: { Screen: () => null },
+ useLocalSearchParams: () => ({ callId: '101' }),
+}));
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+jest.mock('@/components/maps/mapbox', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const passthrough = (name: string) => (props: any) => React.createElement(View, { testID: props.testID ?? `mock-${name}` }, props.children);
+ return {
+ __esModule: true,
+ default: {
+ MapView: passthrough('map-view'),
+ Camera: passthrough('camera'),
+ ShapeSource: passthrough('shape-source'),
+ LineLayer: passthrough('line-layer'),
+ FillLayer: passthrough('fill-layer'),
+ MarkerView: passthrough('marker-view'),
+ },
+ };
+});
+jest.mock('@/components/command/incident-map-layers', () => ({
+ AnnotationLayers: () => null,
+ IncidentLocationMarkers: () => null,
+}));
+jest.mock('@/components/maps/map-pins', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/api/mapping/mapping', () => ({
+ getMapDataAndMarkers: jest.fn(() => Promise.resolve({ Data: { MapMakerInfos: [] } })),
+}));
+jest.mock('@/hooks/use-map-signalr-updates', () => ({ useMapSignalRUpdates: jest.fn(() => jest.fn()) }));
+jest.mock('@/hooks/use-map-geolocation-updates', () => ({
+ useMapGeolocationUpdates: jest.fn(),
+ withLiveLocationsSince: (pins: unknown[]) => pins,
+}));
+jest.mock('@/hooks/use-command-map-overlay', () => ({ useCommandMapOverlay: () => ({}) }));
+jest.mock('@/lib/map-center', () => ({ useDepartmentMapCenter: () => ({ latitude: 39.5, longitude: -119.8 }) }));
+jest.mock('@/lib/map-style', () => ({ useDepartmentMapStyle: () => 'mapbox://styles/mapbox/streets-v12' }));
+jest.mock('@/stores/toast/store', () => {
+ const state = { showToast: jest.fn() };
+ return { useToastStore: (selector: any) => selector(state) };
+});
+jest.mock('@/stores/command/store', () => {
+ const state = {
+ boards: {
+ '101': {
+ board: {
+ Command: { IncidentCommandId: 'ic-1', CallId: 101, MapCenterLatitude: '39.5', MapCenterLongitude: '-119.8', MapZoomLevel: '14' },
+ Maps: [],
+ Annotations: [],
+ },
+ },
+ },
+ updateMapViewEntry: jest.fn(),
+ saveIncidentMapEntry: jest.fn(),
+ saveMapAnnotationEntry: jest.fn(),
+ deleteMapAnnotationEntry: jest.fn(),
+ };
+ return { useCommandStore: (selector: any) => selector(state) };
+});
+jest.mock('@/components/ui/bottom-sheet', () => ({
+ CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null),
+}));
+jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null }));
+
+const mockLegendProps: { current: { isOpen: boolean; onClose: () => void; showHydrant?: boolean } | null } = { current: null };
+jest.mock('@/components/maps/map-legend-sheet', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return {
+ MapLegendSheet: (props: { isOpen: boolean; onClose: () => void; showHydrant?: boolean }) => {
+ mockLegendProps.current = props;
+ return props.isOpen ? React.createElement(View, { testID: 'mock-map-legend-sheet' }) : null;
+ },
+ };
+});
+
+describe('Command map: map legend', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockLegendProps.current = null;
+ });
+
+ it('shows a labelled legend button on the tactical map', async () => {
+ render();
+
+ const button = await screen.findByTestId('map-legend-button');
+ expect(screen.getByTestId('command-map-screen')).toBeTruthy();
+ expect(button.props.accessibilityLabel).toBe('maps.legend.title');
+ expect(button).toHaveStyle({ position: 'absolute', right: 20, width: 48, height: 48 });
+ expect(mockLegendProps.current?.isOpen).toBe(false);
+ });
+
+ it('opens the legend sheet from the button and closes it again', async () => {
+ render();
+
+ fireEvent.press(await screen.findByTestId('map-legend-button'));
+
+ expect(screen.getByTestId('mock-map-legend-sheet')).toBeTruthy();
+ expect(mockLegendProps.current?.isOpen).toBe(true);
+ // The tactical map draws only the incident's units and personnel, never hydrants.
+ expect(mockLegendProps.current?.showHydrant).toBeFalsy();
+
+ act(() => {
+ mockLegendProps.current?.onClose();
+ });
+ expect(screen.queryByTestId('mock-map-legend-sheet')).toBeNull();
+ });
+});
diff --git a/src/__tests__/map-screen-legend.test.tsx b/src/__tests__/map-screen-legend.test.tsx
new file mode 100644
index 0000000..f703f96
--- /dev/null
+++ b/src/__tests__/map-screen-legend.test.tsx
@@ -0,0 +1,163 @@
+// Main map screen (src/app/(app)/index.tsx): the map legend control and sheet.
+jest.mock('nativewind', () => ({
+ styled: jest.fn((Component: any) => Component),
+ cssInterop: jest.fn((Component: any) => Component),
+ useColorScheme: jest.fn(() => ({
+ colorScheme: 'light',
+ get: jest.fn(() => 'light'),
+ setColorScheme: jest.fn(),
+ toggleColorScheme: jest.fn(),
+ })),
+ __esModule: true,
+}));
+
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import Map from '@/app/(app)/index';
+import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
+import { locationService } from '@/services/location';
+import { useLocationStore } from '@/stores/app/location-store';
+
+jest.mock('react-native-safe-area-context', () => ({
+ SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children,
+ useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }),
+}));
+jest.mock('@/hooks/use-app-lifecycle');
+jest.mock('@/stores/app/location-store');
+jest.mock('@/services/location');
+jest.mock('@/hooks/use-map-signalr-updates', () => ({
+ useMapSignalRUpdates: jest.fn(),
+}));
+jest.mock('@/hooks/use-map-geolocation-updates', () => ({
+ useMapGeolocationUpdates: jest.fn(),
+ withLiveLocationsSince: (pins: unknown[]) => pins,
+}));
+const mockGetMapDataAndMarkers = jest.fn();
+jest.mock('@/api/mapping/mapping', () => ({
+ getMapDataAndMarkers: (...args: unknown[]) => mockGetMapDataAndMarkers(...args),
+}));
+jest.mock('@rnmapbox/maps', () => ({
+ setAccessToken: jest.fn(),
+ MapView: 'MapView',
+ Camera: 'Camera',
+ PointAnnotation: 'PointAnnotation',
+ ShapeSource: 'ShapeSource',
+ FillLayer: 'FillLayer',
+ LineLayer: 'LineLayer',
+ MarkerView: 'MarkerView',
+ StyleURL: { Street: 'mapbox://styles/mapbox/streets-v11' },
+ UserTrackingMode: { Follow: 'follow', FollowWithHeading: 'followWithHeading' },
+}));
+jest.mock('expo-router', () => ({
+ router: { push: jest.fn() },
+ Stack: { Screen: () => null },
+ useFocusEffect: jest.fn(),
+}));
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+jest.mock('@/stores/toast/store', () => {
+ const state = { showToast: jest.fn() };
+ const useToastStore = (selector: any) => (typeof selector === 'function' ? selector(state) : state);
+ useToastStore.getState = () => state;
+ return { useToastStore };
+});
+jest.mock('@/stores/app/core-store', () => {
+ const storeState = { setActiveCall: jest.fn(), isInitialized: true, config: null };
+ const mockFn = jest.fn((selector) => (typeof selector === 'function' ? selector(storeState) : storeState)) as jest.Mock & { getState: () => typeof storeState };
+ mockFn.getState = () => storeState;
+ return { useCoreStore: mockFn };
+});
+jest.mock('@/components/maps/map-pins', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/components/maps/pin-detail-modal', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/components/maps/map-layers-sheet', () => ({ MapLayersSheet: () => null }));
+jest.mock('@/components/weather-alerts/weather-alert-banner', () => ({ WeatherAlertBanner: () => null }));
+jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) }));
+jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null }));
+
+// The legend sheet itself is covered by map-legend-sheet.test.tsx: here, only what the screen hands it.
+const mockLegendProps: { current: { isOpen: boolean; onClose: () => void; showHydrant?: boolean } | null } = { current: null };
+jest.mock('@/components/maps/map-legend-sheet', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return {
+ MapLegendSheet: (props: { isOpen: boolean; onClose: () => void; showHydrant?: boolean }) => {
+ mockLegendProps.current = props;
+ return props.isOpen ? React.createElement(View, { testID: 'mock-map-legend-sheet' }) : null;
+ },
+ };
+});
+
+const locationState = { latitude: 40.7128, longitude: -74.006, heading: 0, isMapLocked: false };
+
+describe('Main map: map legend', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockLegendProps.current = null;
+ (useLocationStore as unknown as jest.Mock).mockImplementation((selector: any) => (typeof selector === 'function' ? selector(locationState) : locationState));
+ (useAppLifecycle as jest.Mock).mockReturnValue({ isActive: true, appState: 'active', isBackground: false, lastActiveTimestamp: Date.now() });
+ (locationService.startLocationUpdates as jest.Mock) = jest.fn().mockResolvedValue(undefined);
+ (locationService.stopLocationUpdates as jest.Mock) = jest.fn();
+ mockGetMapDataAndMarkers.mockResolvedValue({ Data: { MapMakerInfos: [] } });
+ });
+
+ it('shows a labelled legend button stacked above the layers button', async () => {
+ render();
+
+ const button = await screen.findByTestId('map-legend-button');
+ expect(button.props.accessibilityLabel).toBe('maps.legend.title');
+ expect(button.props.accessibilityRole).toBe('button');
+ // 132 above the bottom safe-area inset (34): the slot above the layers control.
+ expect(button).toHaveStyle({ position: 'absolute', right: 20, bottom: 166, width: 48, height: 48 });
+ expect(screen.getByTestId('layers-button')).toBeTruthy();
+ expect(screen.queryByTestId('mock-map-legend-sheet')).toBeNull();
+ expect(mockLegendProps.current?.isOpen).toBe(false);
+ });
+
+ it('stacks recenter, layers and legend in their own slots, so none covers another', async () => {
+ render();
+ await screen.findByTestId('map-legend-button');
+ expect(screen.queryByTestId('recenter-button')).toBeNull();
+
+ // The user pans the map: the recenter control appears in the bottom slot.
+ act(() => {
+ screen.UNSAFE_getByType('MapView' as any).props.onCameraChanged({ properties: { isUserInteraction: true } });
+ });
+
+ // Above the bottom safe-area inset (34): recenter 20, layers 76, legend 132.
+ expect(screen.getByTestId('recenter-button')).toHaveStyle({ bottom: 54 });
+ expect(screen.getByTestId('layers-button')).toHaveStyle({ bottom: 110 });
+ expect(screen.getByTestId('map-legend-button')).toHaveStyle({ bottom: 166 });
+ });
+
+ it('opens the legend sheet from the button and closes it again', async () => {
+ render();
+
+ fireEvent.press(await screen.findByTestId('map-legend-button'));
+
+ expect(screen.getByTestId('mock-map-legend-sheet')).toBeTruthy();
+ expect(mockLegendProps.current?.isOpen).toBe(true);
+ expect(mockLegendProps.current?.showHydrant).toBe(false);
+
+ act(() => {
+ mockLegendProps.current?.onClose();
+ });
+ expect(screen.queryByTestId('mock-map-legend-sheet')).toBeNull();
+ });
+
+ it('asks the legend to list hydrants only when the map is drawing hydrant markers', async () => {
+ mockGetMapDataAndMarkers.mockResolvedValue({
+ Data: {
+ MapMakerInfos: [
+ { Id: 'u1', Type: 1, Title: 'Engine 1', Latitude: 40, Longitude: -74, ImagePath: 'Engine_Available' },
+ { Id: 'hydrant-9', Type: 5, Title: 'H-9', Latitude: 40, Longitude: -74, ImagePath: 'hydrant' },
+ ],
+ },
+ });
+
+ render();
+
+ await waitFor(() => expect(mockLegendProps.current?.showHydrant).toBe(true));
+ });
+});
diff --git a/src/api/mapping/__tests__/mapIconLegend.test.ts b/src/api/mapping/__tests__/mapIconLegend.test.ts
new file mode 100644
index 0000000..c6fe9cf
--- /dev/null
+++ b/src/api/mapping/__tests__/mapIconLegend.test.ts
@@ -0,0 +1,93 @@
+import { createCachedApiEndpoint } from '../../common/cached-client';
+import { createApiEndpoint } from '../../common/client';
+import { getMapIconLegend } from '../mapping';
+
+jest.mock('../../common/client', () => {
+ const get = jest.fn();
+ return {
+ createApiEndpoint: jest.fn(() => ({ get })),
+ __mockGet: get,
+ };
+});
+
+jest.mock('../../common/cached-client', () => ({
+ createCachedApiEndpoint: jest.fn(() => ({ get: jest.fn() })),
+}));
+
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.dev/api/v4'),
+}));
+
+const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock };
+
+// Endpoints are created at module load; capture them before beforeEach clears the mocks.
+const uncachedPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]);
+const cachedPaths = (createCachedApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]);
+
+const envelope = {
+ PageSize: 3,
+ Status: 'success',
+ Environment: 'dev',
+ RequestId: 'req-1',
+ Version: 'v4',
+ Timestamp: '2026-10-25T12:00:00Z',
+ Node: 'node-1',
+ Data: {
+ StatusStyles: [
+ {
+ Key: 'available',
+ Color: '#28A745',
+ Meanings: [0],
+ UnitStatuses: ['In Quarters'],
+ PersonnelStatuses: ['Standing By'],
+ Priorities: [],
+ UnitPinIconUrl: 'https://api.resgrid.dev/pin-unit',
+ PersonPinIconUrl: 'https://api.resgrid.dev/pin-person',
+ CallPinIconUrl: null,
+ },
+ ],
+ PriorityStyles: [],
+ Calls: [{ Id: null, Name: 'Calls with no type', StatusSetName: null, IconUrl: null, PinIconUrl: null, ImagePath: 'Call', States: [] }],
+ Units: [],
+ Personnel: [],
+ PoiTypes: [],
+ },
+};
+
+describe('getMapIconLegend', () => {
+ beforeEach(() => {
+ mockGet.mockReset();
+ });
+
+ it('targets Mapping/GetMapIconLegend through the uncached client, so every open sees the department as it is now', () => {
+ expect(uncachedPaths).toContain('/Mapping/GetMapIconLegend');
+ expect(cachedPaths).not.toContain('/Mapping/GetMapIconLegend');
+ });
+
+ it('sends no query parameters and returns the whole v4 envelope', async () => {
+ mockGet.mockResolvedValueOnce({ data: envelope });
+
+ const result = await getMapIconLegend();
+
+ expect(result).toBe(envelope);
+ expect(result.Data.StatusStyles?.[0].UnitStatuses).toEqual(['In Quarters']);
+ expect(result.Data.Calls?.[0].Id).toBeNull();
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ expect(mockGet).toHaveBeenCalledWith(undefined, undefined);
+ });
+
+ it('forwards the abort signal', async () => {
+ const controller = new AbortController();
+ mockGet.mockResolvedValueOnce({ data: envelope });
+
+ await getMapIconLegend(controller.signal);
+
+ expect(mockGet).toHaveBeenCalledWith(undefined, controller.signal);
+ });
+
+ it('propagates request failures to the caller', async () => {
+ mockGet.mockRejectedValueOnce(new Error('Network Error'));
+
+ await expect(getMapIconLegend()).rejects.toThrow('Network Error');
+ });
+});
diff --git a/src/api/mapping/mapping.ts b/src/api/mapping/mapping.ts
index 269de84..9d3075a 100644
--- a/src/api/mapping/mapping.ts
+++ b/src/api/mapping/mapping.ts
@@ -2,6 +2,7 @@ import { type FeatureCollection } from 'geojson';
import { getBaseApiUrl } from '@/lib/storage/app';
import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult';
+import { type GetMapIconLegendResult } from '@/models/v4/mapping/getMapIconLegendResult';
import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult';
import {
type GetAllActiveLayersResult,
@@ -23,6 +24,8 @@ import { createApiEndpoint } from '../common/client';
const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers');
const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers');
+// Not cached: the legend is fetched each time it opens so renamed types, new statuses and new uploads show at once.
+const getMapIconLegendApi = createApiEndpoint('/Mapping/GetMapIconLegend');
const getPoisApi = createCachedApiEndpoint('/Mapping/GetPois', {
ttl: 5 * 60 * 1000,
enabled: true,
@@ -73,6 +76,15 @@ export const getMapDataAndMarkers = async (signal?: AbortSignal) => {
return response.data;
};
+/**
+ * Every icon the department's map can show (status / priority colours, call types, unit types, roles, POI types),
+ * named with the department's own names and drawn from the same icons as the map markers.
+ */
+export const getMapIconLegend = async (signal?: AbortSignal) => {
+ const response = await getMapIconLegendApi.get(undefined, signal);
+ return response.data;
+};
+
export const getPoiTypes = async () => {
const response = await getPoiTypesApi.get();
return response.data;
diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx
index 7e09779..b225099 100644
--- a/src/app/(app)/command.tsx
+++ b/src/app/(app)/command.tsx
@@ -416,6 +416,18 @@ export default function CommandBoard() {
[units, users, boards, activeBoardCallId]
);
+ // Unit type icon for a lane resource — department units only (ad-hoc units have no department type).
+ const resolveResourceIcon = useCallback(
+ (kind: number, resourceId: string) => {
+ if (kind !== ResourceAssignmentKind.RealUnit && kind !== ResourceAssignmentKind.LinkedDeptUnit) {
+ return null;
+ }
+ const unit = units.find((u) => u.UnitId === resourceId);
+ return unit ? { iconUrl: unit.TypeIconUrl, mapIcon: unit.TypeMapIcon } : null;
+ },
+ [units]
+ );
+
const resourceOptions = useMemo(() => {
const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
const activeAssignments = (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn);
@@ -427,6 +439,8 @@ export default function CommandBoard() {
name: u.Name,
detail: [u.Type, u.GroupName].filter(Boolean).join(' • '),
statusLabel: unitCurrentStatuses.find((s) => s.UnitId === u.UnitId)?.State,
+ typeIconUrl: u.TypeIconUrl,
+ typeMapIcon: u.TypeMapIcon,
assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealUnit, u.UnitId),
}));
const personnelOptions = users.map((u) => ({
@@ -853,6 +867,7 @@ export default function CommandBoard() {
onOpenLaneChat={handleOpenLaneChat}
onMoveResource={handleMoveResource}
onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })}
+ resolveResourceIcon={resolveResourceIcon}
resolveResourceName={resolveResourceName}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
@@ -868,6 +883,7 @@ export default function CommandBoard() {
onMoveResource={handleMoveResource}
onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })}
resolveLeadName={resolveLeadName}
+ resolveResourceIcon={resolveResourceIcon}
resolveResourceName={resolveResourceName}
/>
)}
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index 0408133..b03119b 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -9,6 +9,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { Loading } from '@/components/common/loading';
import { MapLayersSheet } from '@/components/maps/map-layers-sheet';
+import { MapLegendButton } from '@/components/maps/map-legend-button';
+import { MapLegendSheet } from '@/components/maps/map-legend-sheet';
import MapPins from '@/components/maps/map-pins';
import Mapbox from '@/components/maps/mapbox';
import PinDetailModal from '@/components/maps/pin-detail-modal';
@@ -21,7 +23,7 @@ import { useMapGeolocationUpdates, withLiveLocationsSince } from '@/hooks/use-ma
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
import { logger } from '@/lib/logging';
import { useDepartmentMapCenter } from '@/lib/map-center';
-import { getPinEntityId } from '@/lib/map-pin-ids';
+import { getPinEntityId, MapPinType } from '@/lib/map-pin-ids';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { getMapboxAccessToken } from '@/lib/mapbox-token';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
@@ -99,6 +101,9 @@ function MapContent() {
const commandOverlay = useCommandMapOverlay();
const [isLayersSheetOpen, setIsLayersSheetOpen] = useState(false);
+ const [isLegendSheetOpen, setIsLegendSheetOpen] = useState(false);
+ // The legend lists hydrants only when this map is drawing some (hydrants module on and visible to this user).
+ const hasHydrantPins = useMemo(() => mapPins.some((pin) => pin.Type === MapPinType.Hydrant), [mapPins]);
// Stable initial camera settings so the native Camera renders at the
// correct position from the very first frame (fixes Android/iOS centering).
@@ -371,6 +376,11 @@ function MapContent() {
setIsPinDetailModalOpen(true);
}, []);
+ const handleOpenLayersSheet = useCallback(() => setIsLayersSheetOpen(true), []);
+ const handleCloseLayersSheet = useCallback(() => setIsLayersSheetOpen(false), []);
+ const handleOpenLegendSheet = useCallback(() => setIsLegendSheetOpen(true), []);
+ const handleCloseLegendSheet = useCallback(() => setIsLegendSheetOpen(false), []);
+
const handleSetAsCurrentCall = async (pin: MapMakerInfoData) => {
try {
logger.info({
@@ -408,6 +418,25 @@ function MapContent() {
// Create dynamic styles based on theme - useMemo to avoid new objects every render
const themedStyles = useMemo(() => {
const isDark = colorScheme === 'dark';
+ // The round map controls stack up the right edge above the bottom safe area: recenter (when shown), layers, legend.
+ const mapControl = {
+ position: 'absolute' as const,
+ right: 20,
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: '#3b82f6',
+ justifyContent: 'center' as const,
+ alignItems: 'center' as const,
+ elevation: 5,
+ shadowColor: isDark ? '#ffffff' : '#000000',
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: isDark ? 0.1 : 0.25,
+ shadowRadius: 3.84,
+ };
return {
markerInnerContainer: {
width: 24,
@@ -428,23 +457,12 @@ function MapContent() {
shadowRadius: 3.84,
},
recenterButton: {
- position: 'absolute' as const,
+ ...mapControl,
bottom: 20 + insets.bottom,
- right: 20,
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: '#3b82f6',
- justifyContent: 'center' as const,
- alignItems: 'center' as const,
- elevation: 5,
- shadowColor: isDark ? '#ffffff' : '#000000',
- shadowOffset: {
- width: 0,
- height: 2,
- },
- shadowOpacity: isDark ? 0.1 : 0.25,
- shadowRadius: 3.84,
+ },
+ layersButton: {
+ ...mapControl,
+ bottom: 76 + insets.bottom,
},
};
}, [colorScheme, insets.bottom]);
@@ -535,10 +553,13 @@ function MapContent() {
) : null}
{/* Layers / maps browser control */}
- setIsLayersSheetOpen(true)} testID="layers-button">
+
+ {/* Map icon legend control, stacked above the layers button */}
+
+
{/* Recenter Button - only show when map is not locked and user has moved the map */}
{showRecenterButton ? (
@@ -551,7 +572,10 @@ function MapContent() {
{/* Layer toggles + custom/indoor maps browser */}
- setIsLayersSheetOpen(false)} />
+
+
+ {/* Map icon legend, fetched each time it opens */}
+
>
);
}
diff --git a/src/app/(app)/poi/[id].tsx b/src/app/(app)/poi/[id].tsx
index d9171cb..7eacc36 100644
--- a/src/app/(app)/poi/[id].tsx
+++ b/src/app/(app)/poi/[id].tsx
@@ -14,6 +14,7 @@ import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { TypeIcon } from '@/components/units/unit-type-icon';
import { openMapsWithDirections } from '@/lib/navigation';
import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils';
import { useLocationStore } from '@/stores/app/location-store';
@@ -83,6 +84,7 @@ export default function PoiDetailScreen() {
{displayName}
+
{poiTypeName}
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index d3135a7..18f56b4 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -33,6 +33,7 @@ import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { useAnalytics } from '@/hooks/use-analytics';
import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted';
import { logger } from '@/lib/logging';
@@ -474,7 +475,10 @@ export default function CallDetail() {
{callExtraData.Dispatches.map((dispatched, index) => (
- {dispatched.Name}
+
+
+ {dispatched.Name}
+
{t('call_detail.group')}: {dispatched.Group}
@@ -593,16 +597,19 @@ export default function CallDetail() {
{/* Header */}
-
- {/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
- {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
-
- ) : (
- <>
- {call.Name} ({call.Number})
- >
- )}
-
+
+
+
+ {/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
+ {isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
+
+ ) : (
+ <>
+ {call.Name} ({call.Number})
+ >
+ )}
+
+
{/* Start Command opens (or creates) this call's IC board — multiple boards may exist */}
{hasCommandBoard && activeBoardCallId === call.CallId ? (
diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx
index f391a93..e3d25a4 100644
--- a/src/app/call/__tests__/[id].security.test.tsx
+++ b/src/app/call/__tests__/[id].security.test.tsx
@@ -91,6 +91,11 @@ jest.mock('expo-clipboard', () => ({
setStringAsync: jest.fn(),
}));
+// Dispatched rows show the unit type icon (expo-image); the native module can't load under the react-native mock.
+jest.mock('expo-image', () => ({
+ Image: () => null,
+}));
+
// Mock storage
jest.mock('@/lib/storage', () => ({
getItem: jest.fn(),
diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx
index 7a197a8..62ae2f1 100644
--- a/src/app/call/__tests__/[id].test.tsx
+++ b/src/app/call/__tests__/[id].test.tsx
@@ -104,6 +104,11 @@ jest.mock('expo-clipboard', () => ({
setStringAsync: jest.fn(),
}));
+// Dispatched rows show the unit type icon (expo-image); the native module can't load under the react-native mock.
+jest.mock('expo-image', () => ({
+ Image: () => null,
+}));
+
jest.mock('expo-constants', () => ({
expoConfig: {
extra: {
diff --git a/src/app/command-map/[callId].tsx b/src/app/command-map/[callId].tsx
index 03aa294..2ce357f 100644
--- a/src/app/command-map/[callId].tsx
+++ b/src/app/command-map/[callId].tsx
@@ -7,6 +7,8 @@ import { StyleSheet, View } from 'react-native';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { AnnotationLayers, IncidentLocationMarkers } from '@/components/command/incident-map-layers';
import ZeroState from '@/components/common/zero-state';
+import { MapLegendButton } from '@/components/maps/map-legend-button';
+import { MapLegendSheet } from '@/components/maps/map-legend-sheet';
import MapPins from '@/components/maps/map-pins';
import Mapbox from '@/components/maps/mapbox';
import { AlertDialog, AlertDialogBackdrop, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader } from '@/components/ui/alert-dialog';
@@ -67,6 +69,7 @@ export default function CommandMapScreen() {
const [pendingTextCoord, setPendingTextCoord] = useState<[number, number] | null>(null);
const [textLabel, setTextLabel] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState(null);
+ const [isLegendOpen, setIsLegendOpen] = useState(false);
// Latest camera framing, captured from map movement so "Save View" pins exactly what's on screen.
const cameraStateRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
@@ -269,6 +272,9 @@ export default function CommandMapScreen() {
showToast(ok ? 'success' : 'error', ok ? t('command.incident_map_markup_removed') : t('command.incident_map_markup_error'));
}, [pendingDeleteId, callId, deleteMapAnnotationEntry, showToast, t]);
+ const handleOpenLegend = useCallback(() => setIsLegendOpen(true), []);
+ const handleCloseLegend = useCallback(() => setIsLegendOpen(false), []);
+
const draftShape = useMemo(() => {
const features: GeoJSON.Feature[] = [];
if (draftPoints.length >= 2) {
@@ -344,6 +350,9 @@ export default function CommandMapScreen() {
)}
+ {/* Map icon legend control */}
+
+
{/* Text label input */}
@@ -357,6 +366,9 @@ export default function CommandMapScreen() {
+ {/* Map icon legend, fetched each time it opens */}
+
+
{/* Delete markup confirmation */}
setPendingDeleteId(null)}>
diff --git a/src/components/calls/__tests__/call-card-type-icon.test.tsx b/src/components/calls/__tests__/call-card-type-icon.test.tsx
new file mode 100644
index 0000000..c41f771
--- /dev/null
+++ b/src/components/calls/__tests__/call-card-type-icon.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('lucide-react-native', () => {
+ const React = require('react');
+ const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` });
+ return {
+ AlertTriangle: icon('alert-triangle'),
+ ClipboardList: icon('clipboard-list'),
+ MapPin: icon('map-pin'),
+ Phone: icon('phone'),
+ Timer: icon('timer'),
+ };
+});
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ return { Image: (props: any) => React.createElement('View', props) };
+});
+
+jest.mock('@/components/ui/html-renderer', () => ({
+ HtmlRenderer: () => null,
+}));
+
+import { MAP_ICONS } from '@/constants/map-icons';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+
+import { CallCard } from '../call-card';
+
+const baseCall = {
+ CallId: '42',
+ Number: '2026-42',
+ Name: 'Structure fire',
+ Nature: '',
+ Address: '1 Main St',
+ Priority: 0,
+ LoggedOnUtc: '2026-10-21T10:00:00Z',
+} as CallResultData;
+
+const getTypeIcon = () => screen.getByTestId('call-card-type-icon-42', { includeHiddenElements: true });
+
+describe('CallCard call type icon', () => {
+ it('shows the uploaded call-type icon in place of the alert glyph', () => {
+ const iconUrl = 'https://api.resgrid.dev/api/v4/TypeIcons/fire-key';
+ render();
+
+ expect(getTypeIcon().props.source).toEqual({ uri: iconUrl });
+ expect(screen.queryByTestId('mock-alert-triangle-icon')).toBeNull();
+ });
+
+ it('uses the built-in call icon the type picked when nothing was uploaded', () => {
+ render();
+
+ expect(getTypeIcon().props.source).toBe(MAP_ICONS.caraccident.uri);
+ });
+
+ it('keeps the alert glyph when older servers omit the type icon fields', () => {
+ render();
+
+ expect(screen.queryByTestId('call-card-type-icon-42', { includeHiddenElements: true })).toBeNull();
+ expect(screen.getByTestId('mock-alert-triangle-icon')).toBeTruthy();
+ });
+});
diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx
index aac83d4..21a1e9d 100644
--- a/src/components/calls/call-card.tsx
+++ b/src/components/calls/call-card.tsx
@@ -10,6 +10,7 @@ import { HtmlRenderer } from '@/components/ui/html-renderer';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { getTimeAgoUtc, readableColors } from '@/lib/utils';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import type { CallResultData } from '@/models/v4/calls/callResultData';
@@ -69,7 +70,7 @@ export const CallCard: React.FC = ({ call, priority, showTimerIco
{/* Header with Call Number and Priority */}
-
+ } iconUrl={call.TypeIconUrl} mapIcon={call.TypeMapIcon} size={24} testID={`call-card-type-icon-${call.CallId}`} />
= ({ call, priority, showTimerIco
return (
-
- {typeLetter}
-
+ {/* Units show their type icon when the department gave the type one; the letter stays the fallback. */}
+
+ {typeLetter}
+
+ }
+ iconUrl={typeLetter === 'U' ? d.TypeIconUrl : null}
+ mapIcon={typeLetter === 'U' ? d.TypeMapIcon : null}
+ size={20}
+ testID={`dispatch-chip-type-icon-${index}`}
+ />
{d.Name}
diff --git a/src/components/calls/destination-poi-selector.tsx b/src/components/calls/destination-poi-selector.tsx
index 2380dc7..6a34c2d 100644
--- a/src/components/calls/destination-poi-selector.tsx
+++ b/src/components/calls/destination-poi-selector.tsx
@@ -8,8 +8,10 @@ import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/
import { CustomBottomSheet } from '../ui/bottom-sheet';
import { Box } from '../ui/box';
import { Button, ButtonText } from '../ui/button';
+import { HStack } from '../ui/hstack';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';
+import { TypeIcon } from '../units/unit-type-icon';
interface DestinationPoiSelectorProps {
destinationPois: PoiResultData[];
@@ -61,7 +63,10 @@ export const DestinationPoiSelector: React.FC = ({
{groupedDestinationPois.length > 0 ? (
groupedDestinationPois.map((group) => (
- {group.title}
+
+
+ {group.title}
+
{group.items.map((poi) => (
diff --git a/src/components/command/__tests__/incident-map-card.test.tsx b/src/components/command/__tests__/incident-map-card.test.tsx
index 62ce8bd..ef96d25 100644
--- a/src/components/command/__tests__/incident-map-card.test.tsx
+++ b/src/components/command/__tests__/incident-map-card.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import React from 'react';
const mockPush = jest.fn();
@@ -32,12 +32,17 @@ jest.mock('@/components/maps/mapbox', () => {
};
});
+// Last props MapPins rendered with, so a test can check what reached the pins (not just which ids).
+const mockMapPinsProps: { current: { pins: Record[]; commandOverlay?: unknown } | null } = { current: null };
jest.mock('@/components/maps/map-pins', () => {
const React = require('react');
const { View } = require('react-native');
return {
__esModule: true,
- default: ({ pins }: { pins: { Id: string }[] }) => React.createElement(View, { testID: 'mock-map-pins', accessibilityLabel: pins.map((p) => p.Id).join(',') }),
+ default: (props: { pins: { Id: string }[]; commandOverlay?: unknown }) => {
+ mockMapPinsProps.current = props;
+ return React.createElement(View, { testID: 'mock-map-pins', accessibilityLabel: props.pins.map((p) => p.Id).join(',') });
+ },
};
});
@@ -149,4 +154,54 @@ describe('IncidentMapCard', () => {
unmount();
});
+
+ it('keeps the uploaded icons on the pins it narrows to this incident (personnel role icon included)', async () => {
+ const roleIconUrl = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
+ const unitIconUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/engine.png';
+ mockOverlay['u5'] = { callId: '101' };
+ mockOverlay['pabc-123'] = { callId: '101' };
+ mockGetMapDataAndMarkers.mockResolvedValue({
+ Data: {
+ MapMakerInfos: [
+ { Id: 'u5', Type: 1, Latitude: 34, Longitude: -118, Title: 'Engine 5', ImagePath: 'Engine_Responding', IconUrl: unitIconUrl },
+ { Id: 'pabc-123', Type: 3, Latitude: 34.1, Longitude: -118.1, Title: 'Alex Reed', ImagePath: 'Person_RespondingCall', IconUrl: roleIconUrl },
+ { Id: 'pdef-456', Type: 3, Latitude: 34.2, Longitude: -118.2, Title: 'No Icon', ImagePath: 'Person_RespondingCall', IconUrl: null },
+ ],
+ },
+ });
+
+ const { getByTestId, unmount } = render();
+
+ await waitFor(() => expect(getByTestId('mock-map-pins').props.accessibilityLabel).toBe('u5,pabc-123'));
+
+ expect(mockMapPinsProps.current?.pins).toEqual([expect.objectContaining({ Id: 'u5', IconUrl: unitIconUrl }), expect.objectContaining({ Id: 'pabc-123', Type: 3, IconUrl: roleIconUrl, ImagePath: 'Person_RespondingCall' })]);
+ // The overlay rides alongside, keyed by marker id, so the lane label/ring is added without rebuilding the pin.
+ expect(mockMapPinsProps.current?.commandOverlay).toBe(mockOverlay);
+
+ unmount();
+ });
+
+ it('keeps the status pin on the incident pins, and a realtime refetch that only changes it reaches the map', async () => {
+ const respondingPin = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1';
+ const onScenePin = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-onscene?v=1';
+ mockOverlay['u5'] = { callId: '101' };
+ const engine = { Id: 'u5', Type: 1, Latitude: 34, Longitude: -118, Title: 'Engine 5', ImagePath: 'Engine_Responding', IconUrl: null, PinIconUrl: respondingPin, IconStyle: 'responding' };
+ mockGetMapDataAndMarkers.mockResolvedValue({ Data: { MapMakerInfos: [engine, { Id: 'u7', Type: 1, Latitude: 34.2, Longitude: -118.2, Title: 'Unassigned Unit', PinIconUrl: respondingPin }] } });
+
+ const { getByTestId, unmount } = render();
+
+ await waitFor(() => expect(getByTestId('mock-map-pins').props.accessibilityLabel).toBe('u5'));
+ expect(mockMapPinsProps.current?.pins).toEqual([expect.objectContaining({ Id: 'u5', PinIconUrl: respondingPin, IconStyle: 'responding' })]);
+
+ // The status-change refetch replaces the snapshot through the setter the realtime hook was handed.
+ const { useMapSignalRUpdates } = jest.requireMock('@/hooks/use-map-signalr-updates') as { useMapSignalRUpdates: jest.Mock };
+ const [setPins] = useMapSignalRUpdates.mock.calls[useMapSignalRUpdates.mock.calls.length - 1];
+ act(() => {
+ setPins([{ ...engine, PinIconUrl: onScenePin, IconStyle: 'onscene' }]);
+ });
+
+ expect(mockMapPinsProps.current?.pins).toEqual([expect.objectContaining({ Id: 'u5', PinIconUrl: onScenePin, IconStyle: 'onscene' })]);
+
+ unmount();
+ });
});
diff --git a/src/components/command/__tests__/resource-cards.test.tsx b/src/components/command/__tests__/resource-cards.test.tsx
index 994f52d..a34f10d 100644
--- a/src/components/command/__tests__/resource-cards.test.tsx
+++ b/src/components/command/__tests__/resource-cards.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render } from '@testing-library/react-native';
+import { act, fireEvent, render } from '@testing-library/react-native';
import React from 'react';
jest.mock('react-i18next', () => ({
@@ -18,6 +18,11 @@ jest.mock('lucide-react-native', () => {
};
});
+jest.mock('expo-image', () => {
+ const React = require('react');
+ return { Image: (props: any) => React.createElement('View', props) };
+});
+
import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
import type { ActiveUnitRoleResultData } from '@/models/v4/unitRoles/activeUnitRoleResultData';
import type { UnitResultData } from '@/models/v4/units/unitResultData';
@@ -79,6 +84,23 @@ describe('UnitResourceCard', () => {
unmount();
});
+
+ it('shows the unit type icon in place of the truck glyph when the type has one, and the truck otherwise', () => {
+ const iconUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/mug.png';
+ const typed = { ...unit, TypeIconUrl: iconUrl, TypeMapIcon: 'ambulance' } as UnitResultData;
+ const { getByTestId, queryByTestId, rerender, unmount } = render();
+
+ // Decorative image, hidden from assistive tech — so the query has to opt in to hidden elements.
+ expect(getByTestId('card-5-type-icon', { includeHiddenElements: true }).props.source).toEqual({ uri: iconUrl });
+ expect(queryByTestId('mock-truck-icon')).toBeNull();
+
+ // Older servers omit the fields: the truck glyph stays.
+ rerender();
+ expect(queryByTestId('card-5-type-icon', { includeHiddenElements: true })).toBeNull();
+ expect(getByTestId('mock-truck-icon')).toBeTruthy();
+
+ unmount();
+ });
});
describe('PersonnelResourceCard', () => {
@@ -107,4 +129,51 @@ describe('PersonnelResourceCard', () => {
unmount();
});
+
+ it('shows the role icon in place of the initials when the person has one', () => {
+ const iconUrl = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
+ const withIcon = { ...person, RoleIconUrl: iconUrl } as PersonnelInfoResultData;
+ const { getByTestId, getByText, queryByText, unmount } = render();
+
+ // Decorative image, hidden from assistive tech — so the query has to opt in to hidden elements.
+ const icon = getByTestId('card-6-role-icon', { includeHiddenElements: true });
+ expect(icon.props.source).toEqual({ uri: iconUrl });
+ expect(icon).toHaveStyle({ width: 24, height: 24 });
+ expect(queryByText('AR')).toBeNull();
+ // Everything else on the card, accessibility labels included, is unchanged.
+ expect(getByText('Alex Reed')).toBeTruthy();
+ expect(getByText('Medic')).toBeTruthy();
+ expect(getByTestId('view-6').props.accessibilityLabel).toBe('command.view_details');
+
+ unmount();
+ });
+
+ it('falls back to the initials when the role icon fails to load', () => {
+ const withIcon = { ...person, RoleIconUrl: 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/gone.png' } as PersonnelInfoResultData;
+ const { getByTestId, getByText, queryByTestId, unmount } = render();
+
+ act(() => {
+ fireEvent(getByTestId('card-7-role-icon', { includeHiddenElements: true }), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(queryByTestId('card-7-role-icon', { includeHiddenElements: true })).toBeNull();
+ expect(getByText('AR')).toBeTruthy();
+
+ unmount();
+ });
+
+ it('keeps the initials when the person has no role icon (null, or omitted by older servers)', () => {
+ const { getByText, queryByTestId, rerender, unmount } = render(
+
+ );
+
+ expect(queryByTestId('card-8-role-icon', { includeHiddenElements: true })).toBeNull();
+ expect(getByText('AR')).toBeTruthy();
+
+ rerender();
+ expect(queryByTestId('card-8-role-icon', { includeHiddenElements: true })).toBeNull();
+ expect(getByText('AR')).toBeTruthy();
+
+ unmount();
+ });
});
diff --git a/src/components/command/add-resource-sheet.tsx b/src/components/command/add-resource-sheet.tsx
index 7411c74..0d7acce 100644
--- a/src/components/command/add-resource-sheet.tsx
+++ b/src/components/command/add-resource-sheet.tsx
@@ -14,6 +14,7 @@ import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import { VStack } from '@/components/ui/vstack';
+import { UnitTypeIcon } from '@/components/units/unit-type-icon';
import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
import type { UnitResultData } from '@/models/v4/units/unitResultData';
import type { UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData';
@@ -127,6 +128,7 @@ export const AddResourceSheet: React.FC = ({ isOpen, onCl
testID={`roster-unit-${unit.UnitId}`}
>
+
{unit.Name}
{subtitle ? {subtitle} : null}
diff --git a/src/components/command/assign-resource-sheet.tsx b/src/components/command/assign-resource-sheet.tsx
index a65094e..b12fd59 100644
--- a/src/components/command/assign-resource-sheet.tsx
+++ b/src/components/command/assign-resource-sheet.tsx
@@ -13,6 +13,7 @@ import { Input, InputField } from '@/components/ui/input';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { UnitTypeIcon } from '@/components/units/unit-type-icon';
import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels';
import { terminologyStore, TermKeys } from '@/stores/terminology/store';
@@ -26,6 +27,9 @@ export interface AssignableResourceOption {
chips?: string[];
/** Live status text (units), rendered as a badge. */
statusLabel?: string;
+ /** Department units only: the unit type's uploaded icon URL / built-in map icon name. */
+ typeIconUrl?: string | null;
+ typeMapIcon?: string | null;
/** Lane the resource currently sits in: undefined = untracked, '' = unassigned pool, else node id. */
assignedNodeId?: string | null;
}
@@ -106,6 +110,7 @@ export const AssignResourceSheet: React.FC = ({ isOpen
onPress={() => setSelected(option)}
>
+
{option.name}
{option.detail ? {option.detail} : null}
diff --git a/src/components/command/landscape-structure-board.tsx b/src/components/command/landscape-structure-board.tsx
index 84f687a..de680cf 100644
--- a/src/components/command/landscape-structure-board.tsx
+++ b/src/components/command/landscape-structure-board.tsx
@@ -12,6 +12,7 @@ import { Icon } from '@/components/ui/icon';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { UnitTypeIcon, type UnitTypeIconSource } from '@/components/units/unit-type-icon';
import { getCommandNodeTypeName } from '@/lib/incident-command-utils';
import { isWeb } from '@/lib/platform';
import { parseUtcMs } from '@/lib/utils';
@@ -115,6 +116,8 @@ interface LandscapeStructureBoardProps {
viewportHeight: number;
viewportWidth: number;
resolveResourceName: (kind: number, resourceId: string) => string;
+ /** Unit type icon for a resource (department units only); omitted or null = no icon. */
+ resolveResourceIcon?: (kind: number, resourceId: string) => UnitTypeIconSource | null;
onAddLane: () => void;
/** Open the lane details editor (leads, linked objectives/need, delete). */
onEditLane?: (nodeId: string) => void;
@@ -129,6 +132,8 @@ interface LandscapeStructureBoardProps {
interface DraggableResourceCardProps {
assignment: ResourceAssignment;
name: string;
+ /** Department unit's type icon, when the resource is one and its type has an icon. */
+ typeIcon?: UnitTypeIconSource | null;
/** Lane MaxTimeInRole (minutes) — flags the resource rotation-due when exceeded. */
rotationAfterMinutes?: number;
/** Lane work-time amber threshold (0 = amber disabled; unset = 20). */
@@ -152,7 +157,7 @@ interface LaneRect {
}
const DraggableResourceCard: React.FC = React.memo(
- ({ assignment, name, rotationAfterMinutes, amberAfterMinutes, redAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onView }) => {
+ ({ assignment, name, typeIcon, rotationAfterMinutes, amberAfterMinutes, redAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onView }) => {
const { t } = useTranslation();
const translation = useRef(new Animated.ValueXY()).current;
const dragReadyRef = useRef(false);
@@ -244,6 +249,7 @@ const DraggableResourceCard: React.FC = React.memo(
+
{name}
@@ -289,6 +295,7 @@ export const LandscapeStructureBoard: React.FC = (
viewportHeight,
viewportWidth,
resolveResourceName,
+ resolveResourceIcon,
onAddLane,
onEditLane,
onOpenLaneChat,
@@ -476,6 +483,7 @@ export const LandscapeStructureBoard: React.FC = (
redAfterMinutes={node.WorkTimeRedMinutes}
isSelected={selectedAssignmentId === assignment.ResourceAssignmentId}
name={resolveResourceName(assignment.ResourceKind, assignment.ResourceId)}
+ typeIcon={resolveResourceIcon?.(assignment.ResourceKind, assignment.ResourceId)}
onDragEnd={() => setDraggingAssignmentId(null)}
onDragStart={setDraggingAssignmentId}
onDrop={(assignmentId, pageX, pageY) => void handleDrop(assignmentId, pageX, pageY)}
diff --git a/src/components/command/resource-cards.tsx b/src/components/command/resource-cards.tsx
index b958660..0ee1721 100644
--- a/src/components/command/resource-cards.tsx
+++ b/src/components/command/resource-cards.tsx
@@ -11,6 +11,7 @@ import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import { VStack } from '@/components/ui/vstack';
+import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { isWeb } from '@/lib/platform';
import { readableColors } from '@/lib/utils';
import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
@@ -94,7 +95,7 @@ export const UnitResourceCard: React.FC = ({ name, unit,
-
+ } iconUrl={unit?.TypeIconUrl} mapIcon={unit?.TypeMapIcon} size={24} testID={`${testID}-type-icon`} />
@@ -161,7 +162,8 @@ export const PersonnelResourceCard: React.FC = ({ na
- {initials}
+ {/* The person's role icon (the same one as their map pin) stands in for the initials; initials when they have none or it fails to load. */}
+ {initials}} iconUrl={person?.RoleIconUrl} size={24} testID={`${testID}-role-icon`} />
{statusDot ? : null}
diff --git a/src/components/command/resource-details-sheet.tsx b/src/components/command/resource-details-sheet.tsx
index 423563f..eb9a59e 100644
--- a/src/components/command/resource-details-sheet.tsx
+++ b/src/components/command/resource-details-sheet.tsx
@@ -16,6 +16,7 @@ import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import { VStack } from '@/components/ui/vstack';
+import { UnitTypeIcon } from '@/components/units/unit-type-icon';
import { parseUtcMs, readableColors } from '@/lib/utils';
import type { MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import type { PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
@@ -188,7 +189,11 @@ export const ResourceDetailsSheet: React.FC = ({
-
+ {kind === 'unit' ? (
+ } iconUrl={unit?.TypeIconUrl} mapIcon={unit?.TypeMapIcon} size={24} testID="resource-details-type-icon" />
+ ) : (
+
+ )}
{kind === 'unit' ? t('command.unit_details_title') : t('command.person_details_title')}
diff --git a/src/components/command/structure-section.tsx b/src/components/command/structure-section.tsx
index ce265af..b7c6053 100644
--- a/src/components/command/structure-section.tsx
+++ b/src/components/command/structure-section.tsx
@@ -11,6 +11,7 @@ import { Icon } from '@/components/ui/icon';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
+import { UnitTypeIcon, type UnitTypeIconSource } from '@/components/units/unit-type-icon';
import { getCommandNodeTypeName } from '@/lib/incident-command-utils';
import { type CommandStructureNode, type ResourceAssignment } from '@/models/v4/incidentCommand/incidentCommandModels';
@@ -20,6 +21,8 @@ interface StructureSectionProps {
nodes: CommandStructureNode[];
assignments: ResourceAssignment[];
resolveResourceName: (kind: number, resourceId: string) => string;
+ /** Unit type icon for a resource (department units only); omitted or null = no icon. */
+ resolveResourceIcon?: (kind: number, resourceId: string) => UnitTypeIconSource | null;
/** Display name for a lane lead slot (resolves user ids to names); external leads pass through. */
resolveLeadName?: (userId?: string | null, externalName?: string | null) => string | null;
onAddLane: () => void;
@@ -38,6 +41,7 @@ export const StructureSection: React.FC = ({
nodes,
assignments,
resolveResourceName,
+ resolveResourceIcon,
resolveLeadName,
onAddLane,
onEditLane,
@@ -160,6 +164,7 @@ export const StructureSection: React.FC = ({
{laneAssignments.map((assignment) => {
const isSelected = selectedAssignmentId === assignment.ResourceAssignmentId;
+ const typeIcon = resolveResourceIcon?.(assignment.ResourceKind, assignment.ResourceId);
return (
= ({
>
+
{resolveResourceName(assignment.ResourceKind, assignment.ResourceId)}
{assignment.ResourceAssignmentId.startsWith('local-') ? : null}
{assignment.RequirementsWarning ? (
diff --git a/src/components/maps/__tests__/map-legend-button.test.tsx b/src/components/maps/__tests__/map-legend-button.test.tsx
new file mode 100644
index 0000000..cd4b8b4
--- /dev/null
+++ b/src/components/maps/__tests__/map-legend-button.test.tsx
@@ -0,0 +1,33 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => ({ top: 0, bottom: 30, left: 0, right: 0 }),
+}));
+
+import { MapLegendButton } from '../map-legend-button';
+
+describe('MapLegendButton', () => {
+ it('is an accessible round control above the bottom safe area that calls onPress', () => {
+ const onPress = jest.fn();
+ render();
+
+ const button = screen.getByTestId('map-legend-button');
+ expect(button.props.accessibilityRole).toBe('button');
+ expect(screen.getByLabelText('maps.legend.title')).toBeTruthy();
+ expect(button).toHaveStyle({ position: 'absolute', right: 20, bottom: 50, width: 48, height: 48, borderRadius: 24, backgroundColor: '#3b82f6' });
+
+ fireEvent.press(button);
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('stacks at the requested offset', () => {
+ render();
+
+ expect(screen.getByTestId('legend')).toHaveStyle({ bottom: 106 });
+ });
+});
diff --git a/src/components/maps/__tests__/map-legend-sheet.test.tsx b/src/components/maps/__tests__/map-legend-sheet.test.tsx
new file mode 100644
index 0000000..7718a07
--- /dev/null
+++ b/src/components/maps/__tests__/map-legend-sheet.test.tsx
@@ -0,0 +1,408 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { Image } from 'react-native';
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ return { Image: (props: any) => React.createElement('View', props) };
+});
+
+// Real English strings (with {{interpolation}}), so the assertions read like the screen and every key must exist.
+jest.mock('react-i18next', () => {
+ const en = require('../../../translations/en.json');
+ const lookup = (key: string) => key.split('.').reduce((node: any, part) => (node == null ? undefined : node[part]), en);
+ return {
+ useTranslation: () => ({
+ t: (key: string, options?: Record) => {
+ const value = lookup(key);
+ return typeof value === 'string' ? value.replace(/{{(\w+)}}/g, (_: string, name: string) => String(options?.[name] ?? '')) : key;
+ },
+ }),
+ };
+});
+
+jest.mock('@/components/ui/bottom-sheet', () => ({
+ CustomBottomSheet: ({ children, isOpen }: any) => (isOpen ? children : null),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
+}));
+
+// Department terminology: renamed module names by term key.
+const mockTerms: Record = {};
+jest.mock('@/stores/terminology/store', () => ({
+ TermKeys: { Calls: 'module.calls', Units: 'module.units', Personnel: 'module.personnel' },
+ useTerm: (key: string, fallback: string) => mockTerms[key] || fallback,
+}));
+
+const mockGetMapIconLegend = jest.fn();
+jest.mock('@/api/mapping/mapping', () => ({
+ getMapIconLegend: (...args: unknown[]) => mockGetMapIconLegend(...args),
+}));
+
+import { MAP_ICONS } from '@/constants/map-icons';
+
+import { MapLegendSheet } from '../map-legend-sheet';
+
+const PIN = (name: string) => `https://api.resgrid.dev/api/v4/TypeIcons/Pins/${name}?v=1`;
+const UPLOAD = (name: string) => `https://api.resgrid.dev/api/v4/TypeIcons/${name}?v=1`;
+
+const style = (Key: string, Color: string, extra: Record = {}) => ({
+ Key,
+ Color,
+ Meanings: [],
+ UnitStatuses: [],
+ PersonnelStatuses: [],
+ Priorities: [],
+ UnitPinIconUrl: null,
+ PersonPinIconUrl: null,
+ CallPinIconUrl: null,
+ ...extra,
+});
+
+const legendData = () => ({
+ StatusStyles: [
+ style('available', '#28A745', { UnitStatuses: ['In Quarters', 'Ready Reserve'], PersonnelStatuses: ['Standing By'], UnitPinIconUrl: PIN('engine-available'), PersonPinIconUrl: PIN('person-available') }),
+ style('responding', '#007BFF', { UnitStatuses: ['Code 3'], PersonnelStatuses: [], UnitPinIconUrl: PIN('engine-responding'), PersonPinIconUrl: PIN('person-responding') }),
+ style('unknown', '#9E9E9E', { UnitStatuses: ['Fueling'], UnitPinIconUrl: null, PersonPinIconUrl: null }),
+ ],
+ PriorityStyles: [style('emergency', '#DC3545', { Priorities: ['P1 Urgent', 'Mayday'], CallPinIconUrl: PIN('call-emergency') }), style('unknown', '#9E9E9E', { Priorities: ['Info Only'] })],
+ Calls: [
+ {
+ Id: null,
+ Name: 'Server none label (calls)',
+ StatusSetName: null,
+ IconUrl: null,
+ PinIconUrl: null,
+ ImagePath: 'Call',
+ States: [{ Id: 1, Name: 'P1 Urgent', Color: '#ff0000', IconStyle: 'emergency', IconUrl: null, PinIconUrl: PIN('call-emergency'), ImagePath: 'Call', IsVariant: false }],
+ },
+ {
+ Id: 12,
+ Name: 'Structure Fire',
+ StatusSetName: null,
+ IconUrl: UPLOAD('structure-fire'),
+ PinIconUrl: null,
+ ImagePath: 'fire',
+ States: [
+ // Upload for this exact priority: wins over the styled pin.
+ { Id: 1, Name: 'P1 Urgent', Color: '#ff0000', IconStyle: 'emergency', IconUrl: UPLOAD('structure-fire-p1'), PinIconUrl: PIN('fire-emergency'), ImagePath: 'fire', IsVariant: true },
+ // No upload: the styled pin.
+ { Id: 2, Name: 'Info Only', Color: null, IconStyle: 'unknown', IconUrl: null, PinIconUrl: PIN('fire-unknown'), ImagePath: 'fire', IsVariant: false },
+ // Neither (older server / no meaning): the bundled asset.
+ { Id: 3, Name: 'Routine', Color: 'not-a-colour', IconStyle: 'unknown', IconUrl: null, PinIconUrl: null, ImagePath: 'fire', IsVariant: false },
+ ],
+ },
+ ],
+ Units: [
+ {
+ Id: null,
+ Name: 'Server none label (units)',
+ StatusSetName: null,
+ IconUrl: null,
+ PinIconUrl: null,
+ ImagePath: 'Engine_Responding',
+ States: [{ Id: 0, Name: 'Ready', Color: '#00ff00', IconStyle: 'available', IconUrl: null, PinIconUrl: PIN('engine-available'), ImagePath: 'Engine_Available', IsVariant: false }],
+ },
+ {
+ Id: 7,
+ Name: 'Brush Truck',
+ StatusSetName: 'Wildland Set',
+ IconUrl: null,
+ PinIconUrl: null,
+ ImagePath: 'fourbyfour',
+ States: [{ Id: 41, Name: 'Mopping Up', Color: '#ffaa00', IconStyle: 'onscene', IconUrl: null, PinIconUrl: PIN('fourbyfour-onscene'), ImagePath: 'fourbyfour', IsVariant: false }],
+ },
+ { Id: 8, Name: 'Tender', StatusSetName: null, IconUrl: null, PinIconUrl: null, ImagePath: 'truck', States: [] },
+ ],
+ Personnel: [
+ { Id: null, Name: 'Server none label (personnel)', StatusSetName: null, IconUrl: null, PinIconUrl: null, ImagePath: 'Person_RespondingCall', States: [] },
+ {
+ Id: 3,
+ Name: 'Medic',
+ StatusSetName: null,
+ IconUrl: UPLOAD('medic'),
+ PinIconUrl: null,
+ ImagePath: 'Person_RespondingCall',
+ States: [{ Id: 5, Name: 'On Shift', Color: null, IconStyle: 'available', IconUrl: UPLOAD('medic-badge'), PinIconUrl: PIN('person-available'), ImagePath: 'Person_Available', IsVariant: false }],
+ },
+ ],
+ PoiTypes: [
+ { PoiTypeId: 4, Name: 'Water Point', Color: '#1e88e5', PoiImage: 'map-icon-water-supply', ImagePath: 'watersupply', Marker: 'SQUARE', IconUrl: null },
+ { PoiTypeId: 5, Name: 'Helispot', Color: '#43a047', PoiImage: 'map-icon-heliport', ImagePath: 'helipad', Marker: 'MAP_PIN', IconUrl: UPLOAD('helispot') },
+ ],
+});
+
+const envelope = (data: unknown = legendData()) => ({ PageSize: 0, Status: 'success', Environment: 'dev', RequestId: 'r', Version: 'v4', Timestamp: '', Node: '', Data: data });
+
+const renderOpen = async (props: Partial> = {}) => {
+ const utils = render();
+ await waitFor(() => expect(screen.getByTestId('map-legend-content')).toBeTruthy());
+ return utils;
+};
+
+describe('MapLegendSheet', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ for (const key of Object.keys(mockTerms)) delete mockTerms[key];
+ mockGetMapIconLegend.mockResolvedValue(envelope());
+ });
+
+ it('does not fetch or render anything while closed', () => {
+ render();
+
+ expect(mockGetMapIconLegend).not.toHaveBeenCalled();
+ expect(screen.queryByText('Map legend')).toBeNull();
+ });
+
+ it("renders the department's own names for statuses, priorities, types, roles and POI types", async () => {
+ await renderOpen();
+
+ expect(screen.getByText('Map legend')).toBeTruthy();
+ expect(screen.getByText('Units: In Quarters, Ready Reserve')).toBeTruthy();
+ expect(screen.getByText('Personnel: Standing By')).toBeTruthy();
+ expect(screen.getByText('Units: Code 3')).toBeTruthy();
+ expect(screen.getByText('Priorities: P1 Urgent, Mayday')).toBeTruthy();
+ expect(screen.getByText('Structure Fire')).toBeTruthy();
+ expect(screen.getByText('Routine')).toBeTruthy();
+ expect(screen.getByText('Brush Truck')).toBeTruthy();
+ expect(screen.getByText('Mopping Up')).toBeTruthy();
+ expect(screen.getByText('Medic')).toBeTruthy();
+ expect(screen.getByText('On Shift')).toBeTruthy();
+ expect(screen.getByText('Water Point')).toBeTruthy();
+ expect(screen.getByText('Helispot')).toBeTruthy();
+ });
+
+ it('omits an empty names line instead of showing a bare label', async () => {
+ await renderOpen();
+
+ // Responding has unit statuses but no personnel statuses: one line, no bare "Personnel:".
+ const responding = screen.getByTestId('map-legend-status-style-responding');
+ const lines = responding.findAll((node: any) => typeof node.props.children === 'string' && /^(Units|Personnel):/.test(node.props.children) && typeof node.type === 'string');
+ expect(lines.map((node: any) => node.props.children)).toEqual(['Units: Code 3']);
+ });
+
+ it('shows the sections in order: status colors, priority colors, calls, units, personnel, other', async () => {
+ await renderOpen();
+
+ const order = ['map-legend-status-colors', 'map-legend-priority-colors', 'map-legend-calls', 'map-legend-units', 'map-legend-personnel', 'map-legend-other'];
+ const content = screen.getByTestId('map-legend-content');
+ expect(content.children.map((child: any) => child.props.testID)).toEqual(order);
+ });
+
+ it('labels the Id null rows with its own localized text, never the server Name', async () => {
+ await renderOpen();
+
+ expect(screen.getByText('Calls with no type')).toBeTruthy();
+ expect(screen.getByText('Units with no type')).toBeTruthy();
+ expect(screen.getByText('Members with no role icon')).toBeTruthy();
+ expect(screen.queryByText(/Server none label/)).toBeNull();
+ expect(screen.getByTestId('map-legend-calls-row-none')).toBeTruthy();
+ });
+
+ it('shows the status set name, or the built-in unit statuses label when there is none', async () => {
+ await renderOpen();
+
+ expect(screen.getByText('Statuses: Wildland Set')).toBeTruthy();
+ // The "no type" row and Tender both use the built-in unit statuses.
+ expect(screen.getAllByText('Built-in unit statuses')).toHaveLength(2);
+ });
+
+ it('labels the styles by key, with the "unknown" style worded per kind', async () => {
+ await renderOpen();
+
+ expect(screen.getByText('Available')).toBeTruthy();
+ expect(screen.getByText('Responding')).toBeTruthy();
+ expect(screen.getByText('Emergency')).toBeTruthy();
+ expect(screen.getByText('No meaning or no status')).toBeTruthy();
+ expect(screen.getByText('No meaning')).toBeTruthy();
+ expect(screen.getByTestId('map-legend-status-style-available-swatch')).toHaveStyle({ backgroundColor: '#28A745' });
+ });
+
+ it('draws the status and priority pins from the style URLs, leaving a style with no pin empty', async () => {
+ await renderOpen();
+
+ expect(screen.getByTestId('map-legend-status-style-available-unit-pin').props.source).toEqual({ uri: PIN('engine-available') });
+ expect(screen.getByTestId('map-legend-status-style-available-person-pin').props.source).toEqual({ uri: PIN('person-available') });
+ expect(screen.getByTestId('map-legend-priority-style-emergency-call-pin').props.source).toEqual({ uri: PIN('call-emergency') });
+ expect(screen.getByTestId('map-legend-status-style-unknown-unit-empty')).toBeTruthy();
+ expect(screen.getByTestId('map-legend-priority-style-unknown-call-empty')).toBeTruthy();
+ });
+
+ it('picks each icon as the map does: IconUrl, else PinIconUrl, else the bundled ImagePath asset', async () => {
+ await renderOpen();
+
+ const row = 'map-legend-calls-row-12';
+ // The type's own upload beside the name.
+ expect(screen.getByTestId(`${row}-icon-upload`).props.source).toEqual({ uri: UPLOAD('structure-fire') });
+ expect(screen.getByTestId(`${row}-state-1-icon-upload`).props.source).toEqual({ uri: UPLOAD('structure-fire-p1') });
+ expect(screen.queryByTestId(`${row}-state-1-icon-pin`)).toBeNull();
+ expect(screen.getByTestId(`${row}-state-2-icon-pin`).props.source).toEqual({ uri: PIN('fire-unknown') });
+ expect(screen.getByTestId(`${row}-state-3-icon-bundled`).props.source).toBe(MAP_ICONS.fire.uri);
+ // A row with no upload shows its bundled type icon; the station is the bundled station pin.
+ expect(screen.getByTestId('map-legend-units-row-7-icon-bundled').props.source).toBe(MAP_ICONS.fourbyfour.uri);
+ expect(screen.getByTestId('map-legend-station-icon-bundled').props.source).toBe(MAP_ICONS.station.uri);
+ });
+
+ it('falls back down the chain when a remote icon fails to load', async () => {
+ await renderOpen();
+
+ const state = 'map-legend-calls-row-12-state-1-icon';
+ act(() => {
+ fireEvent(screen.getByTestId(`${state}-upload`), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.queryByTestId(`${state}-upload`)).toBeNull();
+ expect(screen.getByTestId(`${state}-pin`).props.source).toEqual({ uri: PIN('fire-emergency') });
+
+ act(() => {
+ fireEvent(screen.getByTestId(`${state}-pin`), 'error', { error: 'HTTP 500' });
+ });
+ expect(screen.queryByTestId(`${state}-pin`)).toBeNull();
+ expect(screen.getByTestId(`${state}-bundled`).props.source).toBe(MAP_ICONS.fire.uri);
+ });
+
+ it('draws POI types as the map draws POI markers: the upload, else the coloured shape', async () => {
+ await renderOpen();
+
+ expect(screen.getByTestId('map-legend-poi-5-icon-upload').props.source).toEqual({ uri: UPLOAD('helispot') });
+ expect(screen.getByTestId('map-legend-poi-4-icon-shape')).toBeTruthy();
+ expect(screen.queryByTestId('map-legend-poi-4-icon-upload')).toBeNull();
+ });
+
+ it('gives each icon an accessibility label of row and state', async () => {
+ await renderOpen();
+
+ expect(screen.getByLabelText('Structure Fire · P1 Urgent')).toBeTruthy();
+ expect(screen.getByLabelText('Calls with no type · P1 Urgent')).toBeTruthy();
+ expect(screen.getByLabelText('Brush Truck · Mopping Up')).toBeTruthy();
+ expect(screen.getByLabelText('Available · Units')).toBeTruthy();
+ expect(screen.getByLabelText('Emergency · Calls')).toBeTruthy();
+ expect(screen.getByLabelText('Water Point')).toBeTruthy();
+ });
+
+ it('marks a state with its own colour, ignoring values that are not hex colours', async () => {
+ await renderOpen();
+
+ expect(screen.getByTestId('map-legend-calls-row-12-state-1-dot')).toHaveStyle({ backgroundColor: '#ff0000' });
+ expect(screen.queryByTestId('map-legend-calls-row-12-state-2-dot')).toBeNull();
+ expect(screen.queryByTestId('map-legend-calls-row-12-state-3-dot')).toBeNull();
+ });
+
+ it('uses the department terminology for the calls, units and personnel headings', async () => {
+ mockTerms['module.calls'] = 'Incidents';
+ mockTerms['module.units'] = 'Apparatus';
+ mockTerms['module.personnel'] = 'Members';
+
+ await renderOpen();
+
+ expect(screen.getByText('Incidents')).toBeTruthy();
+ expect(screen.getByText('Apparatus')).toBeTruthy();
+ expect(screen.getByText('Members')).toBeTruthy();
+ expect(screen.getByText('Apparatus: In Quarters, Ready Reserve')).toBeTruthy();
+ expect(screen.getByText('Members: Standing By')).toBeTruthy();
+ });
+
+ it('shows the personnel note and the station, and lists hydrants only when asked', async () => {
+ const { unmount } = await renderOpen();
+
+ expect(screen.getByText("A member shows the icon of the first role they hold that has one. A member On Unit takes the color of their unit's status.")).toBeTruthy();
+ expect(screen.getByText('Station')).toBeTruthy();
+ expect(screen.getByText('Stations and points of interest')).toBeTruthy();
+ expect(screen.queryByTestId('map-legend-hydrant')).toBeNull();
+ unmount();
+
+ await renderOpen({ showHydrant: true });
+ expect(screen.getByTestId('map-legend-hydrant')).toBeTruthy();
+ expect(screen.getByText('Stations, hydrants and points of interest')).toBeTruthy();
+ });
+
+ it('treats missing or null lists as empty, skipping their sections without crashing', async () => {
+ mockGetMapIconLegend.mockResolvedValue(envelope({ StatusStyles: null, Calls: [null, { Id: 2, Name: 'Medical', ImagePath: 'firstaid', States: null }] }));
+
+ await renderOpen();
+
+ expect(screen.queryByTestId('map-legend-status-colors')).toBeNull();
+ expect(screen.queryByTestId('map-legend-priority-colors')).toBeNull();
+ expect(screen.queryByTestId('map-legend-units')).toBeNull();
+ expect(screen.queryByTestId('map-legend-personnel')).toBeNull();
+ expect(screen.getByText('Medical')).toBeTruthy();
+ expect(screen.getByTestId('map-legend-station')).toBeTruthy();
+ });
+
+ it('copes with a response that has no Data at all', async () => {
+ mockGetMapIconLegend.mockResolvedValue({ Status: 'success' });
+
+ await renderOpen();
+
+ expect(screen.queryByTestId('map-legend-calls')).toBeNull();
+ expect(screen.getByTestId('map-legend-station')).toBeTruthy();
+ });
+
+ it('shows a spinner while loading', async () => {
+ let resolve: (value: unknown) => void = () => undefined;
+ mockGetMapIconLegend.mockReturnValue(new Promise((r) => (resolve = r)));
+
+ render();
+
+ expect(screen.getByTestId('map-legend-loading')).toBeTruthy();
+ expect(screen.queryByTestId('map-legend-content')).toBeNull();
+
+ await act(async () => {
+ resolve(envelope());
+ });
+ expect(screen.queryByTestId('map-legend-loading')).toBeNull();
+ expect(screen.getByTestId('map-legend-content')).toBeTruthy();
+ });
+
+ it('shows an error with Retry, and Retry fetches again', async () => {
+ mockGetMapIconLegend.mockRejectedValueOnce(new Error('Network Error'));
+
+ render();
+
+ await waitFor(() => expect(screen.getByTestId('map-legend-error')).toBeTruthy());
+ expect(screen.getByText('Could not load the map legend.')).toBeTruthy();
+ expect(screen.queryByTestId('map-legend-content')).toBeNull();
+
+ fireEvent.press(screen.getByTestId('map-legend-retry'));
+
+ await waitFor(() => expect(screen.getByTestId('map-legend-content')).toBeTruthy());
+ expect(mockGetMapIconLegend).toHaveBeenCalledTimes(2);
+ expect(screen.queryByTestId('map-legend-error')).toBeNull();
+ });
+
+ it('fetches afresh every time it opens, so department changes show on the next open', async () => {
+ const { rerender } = await renderOpen();
+ expect(mockGetMapIconLegend).toHaveBeenCalledTimes(1);
+ expect(mockGetMapIconLegend.mock.calls[0][0]).toBeInstanceOf(AbortSignal);
+
+ rerender();
+ const renamed = legendData();
+ renamed.Calls[1].Name = 'Building Fire';
+ mockGetMapIconLegend.mockResolvedValue(envelope(renamed));
+
+ rerender();
+
+ await waitFor(() => expect(screen.getByText('Building Fire')).toBeTruthy());
+ expect(screen.queryByText('Structure Fire')).toBeNull();
+ expect(mockGetMapIconLegend).toHaveBeenCalledTimes(2);
+ });
+
+ it('abandons a request still in flight when the sheet closes', async () => {
+ mockGetMapIconLegend.mockReturnValue(new Promise(() => undefined));
+ const { rerender } = render();
+ const signal = mockGetMapIconLegend.mock.calls[0][0] as AbortSignal;
+ expect(signal.aborted).toBe(false);
+
+ rerender();
+
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('never draws a bundled image for a style pin', async () => {
+ await renderOpen();
+
+ const styleRow = screen.getByTestId('map-legend-status-style-unknown');
+ expect(styleRow.findAll((node: any) => node.type === Image)).toHaveLength(0);
+ });
+});
diff --git a/src/components/maps/__tests__/map-pins.test.tsx b/src/components/maps/__tests__/map-pins.test.tsx
new file mode 100644
index 0000000..e18289e
--- /dev/null
+++ b/src/components/maps/__tests__/map-pins.test.tsx
@@ -0,0 +1,185 @@
+import { render } from '@testing-library/react-native';
+import React from 'react';
+
+import { ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+const mockPinMarker = jest.fn((_props: any) => null);
+const mockPoiMarker = jest.fn((_props: any) => null);
+const mockMarkerView = jest.fn((_props: any) => undefined);
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/components/maps/mapbox', () => ({
+ __esModule: true,
+ default: {
+ MarkerView: (props: { children: React.ReactNode }) => {
+ mockMarkerView(props);
+ return props.children;
+ },
+ },
+}));
+
+jest.mock('../pin-marker', () => ({
+ __esModule: true,
+ default: (props: any) => mockPinMarker(props),
+}));
+
+jest.mock('../poi-marker', () => ({
+ __esModule: true,
+ default: (props: any) => mockPoiMarker(props),
+}));
+
+import MapPins from '../map-pins';
+
+const pin = (overrides: Partial) =>
+ ({ Id: 'x', Longitude: 4.35, Latitude: 50.85, Title: 'Pin', zIndex: 0, ImagePath: '', InfoWindowContent: '', Color: '', Type: 0, PoiImage: '', ...overrides }) as MapMakerInfoData;
+
+describe('MapPins type icons', () => {
+ beforeEach(() => {
+ mockPinMarker.mockClear();
+ mockPoiMarker.mockClear();
+ mockMarkerView.mockClear();
+ });
+
+ it('hands the uploaded type icon to call, unit and POI markers alike', () => {
+ render(
+
+ );
+
+ const pinUrls = mockPinMarker.mock.calls.map(([props]) => props.iconUrl);
+ expect(pinUrls).toEqual(expect.arrayContaining(['https://api.resgrid.dev/api/v4/TypeIcons/call', 'https://api.resgrid.dev/api/v4/TypeIcons/unit']));
+ expect(mockPoiMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: 'https://api.resgrid.dev/api/v4/TypeIcons/poi', poiImage: 'map-icon-hospital' }));
+ });
+
+ it('passes no icon URL when older servers omit it, leaving the bundled rendering in place', () => {
+ render();
+
+ expect(mockPinMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: undefined, imagePath: 'fire' }));
+ expect(mockPoiMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: undefined }));
+ });
+});
+
+describe('MapPins personnel role icons (Type 3)', () => {
+ const ROLE_ICON_URL = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
+ const personPin = (overrides: Partial = {}) => pin({ Id: 'pabc-123', Type: 3, ImagePath: 'Person_RespondingCall', Title: 'Alex Reed', ...overrides });
+
+ beforeEach(() => {
+ mockPinMarker.mockClear();
+ mockPoiMarker.mockClear();
+ mockMarkerView.mockClear();
+ });
+
+ it('hands the role icon to the person pin as a regular (non-POI), centre-anchored pin', () => {
+ render();
+
+ expect(mockPoiMarker).not.toHaveBeenCalled();
+ expect(mockPinMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: ROLE_ICON_URL, imagePath: 'Person_RespondingCall', size: 32, title: 'Alex Reed' }));
+ expect(mockMarkerView).toHaveBeenCalledWith(expect.objectContaining({ id: 'pin-pabc-123', anchor: { x: 0.5, y: 0.5 } }));
+ });
+
+ it('keeps the role icon alongside the command lane label and ring when the person is on the board', () => {
+ render(
+
+ );
+
+ expect(mockPinMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: ROLE_ICON_URL, laneLabel: 'Rehab', accentColor: '#00aa00' }));
+ });
+
+ it('leaves the bundled person pin in place when the person has no role icon (null, or omitted by older servers)', () => {
+ render();
+
+ expect(mockPinMarker).toHaveBeenCalledTimes(2);
+ for (const [props] of mockPinMarker.mock.calls) {
+ expect(props.iconUrl ?? null).toBeNull();
+ expect(props.imagePath).toBe('Person_RespondingCall');
+ }
+ });
+
+ it('anchors POI pins on their tip and every other pin on its centre', () => {
+ render();
+
+ const anchors = Object.fromEntries(mockMarkerView.mock.calls.map(([props]) => [props.id, props.anchor]));
+ expect(anchors).toEqual({ 'pin-poi1': { x: 0.5, y: 1.0 }, 'pin-pabc-123': { x: 0.5, y: 0.5 }, 'pin-u1': { x: 0.5, y: 0.5 } });
+ });
+});
+
+describe('MapPins status / priority pins (PinIconUrl)', () => {
+ const CALL_HIGH = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/call-high?v=1';
+ const ENGINE_RESPONDING = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1';
+ const ENGINE_ONSCENE = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-onscene?v=1';
+ const PERSON_RESPONDING = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/person-responding?v=1';
+
+ beforeEach(() => {
+ mockPinMarker.mockClear();
+ mockPoiMarker.mockClear();
+ mockMarkerView.mockClear();
+ });
+
+ it('hands the status / priority pin to call, unit and person markers, centre-anchored like the bundled pin', () => {
+ render(
+
+ );
+
+ const byImage = Object.fromEntries(mockPinMarker.mock.calls.map(([props]) => [props.imagePath, props]));
+ expect(byImage.Call).toEqual(expect.objectContaining({ pinIconUrl: CALL_HIGH, iconUrl: undefined, size: 32 }));
+ expect(byImage.Engine_Responding).toEqual(expect.objectContaining({ pinIconUrl: ENGINE_RESPONDING, size: 32 }));
+ expect(byImage.Person_RespondingCall).toEqual(expect.objectContaining({ pinIconUrl: PERSON_RESPONDING, size: 32 }));
+ for (const [props] of mockMarkerView.mock.calls) {
+ expect(props.anchor).toEqual({ x: 0.5, y: 0.5 });
+ }
+ });
+
+ it('passes the uploaded icon and the status pin side by side, leaving the precedence to the marker', () => {
+ const uploadUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/engine.png?s=responding&v=1';
+ render();
+
+ expect(mockPinMarker).toHaveBeenCalledWith(expect.objectContaining({ iconUrl: uploadUrl, pinIconUrl: ENGINE_RESPONDING }));
+ });
+
+ it('leaves POI markers alone and passes nothing when the server sends no pin (stations, older servers)', () => {
+ render(
+
+ );
+
+ expect(mockPoiMarker).toHaveBeenCalledTimes(1);
+ expect(mockPoiMarker.mock.calls[0][0]).not.toHaveProperty('pinIconUrl');
+ expect(mockPinMarker).toHaveBeenCalledTimes(2);
+ for (const [props] of mockPinMarker.mock.calls) {
+ expect(props.pinIconUrl ?? null).toBeNull();
+ }
+ });
+
+ it('redraws a marker when a refetch changes only its status pin', () => {
+ const responding = pin({ Id: 'u1', Type: 1, ImagePath: 'Engine_Responding', PinIconUrl: ENGINE_RESPONDING, IconStyle: 'responding' });
+ const { rerender } = render();
+ expect(mockPinMarker).toHaveBeenLastCalledWith(expect.objectContaining({ pinIconUrl: ENGINE_RESPONDING }));
+
+ // Same pin object (e.g. an unrelated pin moved): the memoised marker is not redrawn.
+ mockPinMarker.mockClear();
+ rerender();
+ expect(mockPinMarker).not.toHaveBeenCalled();
+
+ // The refetch after a status change hands over a new pin that differs only in its pin URL / style.
+ rerender();
+ expect(mockPinMarker).toHaveBeenCalledTimes(1);
+ expect(mockPinMarker).toHaveBeenLastCalledWith(expect.objectContaining({ pinIconUrl: ENGINE_ONSCENE, imagePath: 'Engine_Responding' }));
+ });
+});
diff --git a/src/components/maps/__tests__/map-view-web-anchor.test.tsx b/src/components/maps/__tests__/map-view-web-anchor.test.tsx
new file mode 100644
index 0000000..62ba41e
--- /dev/null
+++ b/src/components/maps/__tests__/map-view-web-anchor.test.tsx
@@ -0,0 +1,128 @@
+/**
+ * Web/Electron MarkerView anchoring: native MarkerView takes a fractional { x, y } anchor, mapbox-gl a named one.
+ * Before the mapping, the web MarkerView dropped `anchor` altogether, so pins native sits on their bottom edge
+ * (POI pins, incident locations, the resource position) were centred on their coordinate on web.
+ *
+ * @jest-environment jsdom
+ */
+import { render } from '@testing-library/react-native';
+import mapboxgl from 'mapbox-gl';
+import React from 'react';
+
+// The token + department-center stores are irrelevant here and pull in the app's storage layer.
+jest.mock('@/lib/mapbox-token', () => ({ onMapboxAccessTokenChange: jest.fn() }));
+jest.mock('@/lib/map-center', () => ({ useDepartmentMapCenter: jest.fn() }));
+
+import { MapContext, MarkerView, PointAnnotation, toMarkerAnchor } from '../map-view.web';
+
+const MockMarker = mapboxgl.Marker as unknown as jest.Mock;
+
+const fakeMap = {} as never;
+
+const renderMarker = (element: React.ReactElement) => render({element});
+
+const lastMarkerOptions = () => MockMarker.mock.calls[MockMarker.mock.calls.length - 1][0];
+
+describe('toMarkerAnchor', () => {
+ it.each([
+ [{ x: 0.5, y: 0.5 }, 'center'],
+ [{ x: 0.5, y: 1 }, 'bottom'],
+ [{ x: 0.5, y: 0 }, 'top'],
+ [{ x: 0, y: 0.5 }, 'left'],
+ [{ x: 1, y: 0.5 }, 'right'],
+ [{ x: 0, y: 0 }, 'top-left'],
+ [{ x: 1, y: 0 }, 'top-right'],
+ [{ x: 0, y: 1 }, 'bottom-left'],
+ [{ x: 1, y: 1 }, 'bottom-right'],
+ // Fractions in between snap to the nearest named anchor.
+ [{ x: 0.5, y: 0.8 }, 'bottom'],
+ [{ x: 0.6, y: 0.4 }, 'center'],
+ ])('maps %j to %s', (anchor, expected) => {
+ expect(toMarkerAnchor(anchor)).toBe(expected);
+ });
+
+ it('passes a named anchor through and defaults to center', () => {
+ expect(toMarkerAnchor('bottom-left')).toBe('bottom-left');
+ expect(toMarkerAnchor(undefined)).toBe('center');
+ expect(toMarkerAnchor(null)).toBe('center');
+ });
+});
+
+describe('web MarkerView anchor', () => {
+ beforeEach(() => {
+ MockMarker.mockClear();
+ });
+
+ it('forwards a bottom-center anchor to the mapbox-gl marker, as native does', () => {
+ const { unmount } = renderMarker();
+
+ expect(MockMarker).toHaveBeenCalledTimes(1);
+ expect(lastMarkerOptions()).toEqual(expect.objectContaining({ anchor: 'bottom', element: expect.any(HTMLDivElement) }));
+
+ unmount();
+ });
+
+ it('centres the marker when no anchor is given', () => {
+ const { unmount } = renderMarker();
+
+ expect(lastMarkerOptions()).toEqual(expect.objectContaining({ anchor: 'center' }));
+
+ unmount();
+ });
+
+ it('gives PointAnnotation the same mapping for fractional and named anchors', () => {
+ const first = renderMarker();
+ expect(lastMarkerOptions()).toEqual(expect.objectContaining({ anchor: 'center' }));
+ first.unmount();
+
+ const second = renderMarker();
+ expect(lastMarkerOptions()).toEqual(expect.objectContaining({ anchor: 'top-right' }));
+ second.unmount();
+ });
+
+ it('keeps the marker across re-renders that hand over an equal anchor object', () => {
+ const { rerender, unmount } = renderMarker();
+
+ rerender(
+
+
+
+ );
+
+ expect(MockMarker).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('rebuilds the marker at the new anchor when the anchor changes for the same id, with its children and click handler', () => {
+ const onSelected = jest.fn();
+ const { rerender, unmount } = renderMarker(
+
+ <>>
+
+ );
+ const firstMarker = MockMarker.mock.results[0].value;
+ const firstElement = lastMarkerOptions().element as HTMLDivElement;
+
+ rerender(
+
+
+ <>>
+
+
+ );
+
+ expect(MockMarker).toHaveBeenCalledTimes(2);
+ expect(firstMarker.remove).toHaveBeenCalled();
+ expect(lastMarkerOptions()).toEqual(expect.objectContaining({ anchor: 'bottom' }));
+
+ // The click handler follows the marker onto its new element and is gone from the old one.
+ const secondElement = lastMarkerOptions().element as HTMLDivElement;
+ firstElement.dispatchEvent(new MouseEvent('click'));
+ expect(onSelected).not.toHaveBeenCalled();
+ secondElement.dispatchEvent(new MouseEvent('click'));
+ expect(onSelected).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+});
diff --git a/src/components/maps/__tests__/pin-marker.test.tsx b/src/components/maps/__tests__/pin-marker.test.tsx
new file mode 100644
index 0000000..c2f5afc
--- /dev/null
+++ b/src/components/maps/__tests__/pin-marker.test.tsx
@@ -0,0 +1,234 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+import { Image, StyleSheet } from 'react-native';
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ return { Image: (props: any) => React.createElement('View', props) };
+});
+
+import { MAP_ICONS } from '@/constants/map-icons';
+
+import PinMarker, { getPinIconUrl } from '../pin-marker';
+
+const ICON_URL = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/pit.png';
+
+describe('PinMarker unit type icon', () => {
+ it('shows the uploaded unit type icon in the pin footprint, keeping the lane ring', () => {
+ render();
+
+ const icon = screen.getByTestId('pin-type-icon');
+ expect(icon.props.source).toEqual({ uri: ICON_URL });
+ expect(icon.props.contentFit).toBe('contain');
+ expect(icon.props.cachePolicy).toBe('memory-disk');
+ expect(icon).toHaveStyle({ width: 32, height: 32, borderColor: '#ff0000', borderRadius: 16 });
+ expect(screen.getByTestId('pin-lane-label')).toBeTruthy();
+ expect(screen.UNSAFE_queryByType(Image)).toBeNull();
+ });
+
+ it('falls back to the bundled icon when the uploaded icon fails to load', () => {
+ render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-type-icon'), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.ambulance.uri);
+ });
+
+ it('keeps the bundled icon when there is no uploaded icon (older servers, non-unit pins)', () => {
+ render();
+
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.cartwo.uri);
+ });
+});
+
+describe('PinMarker personnel role icon (Type 3 pins)', () => {
+ const ROLE_ICON_URL = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
+ // What GetMapDataAndMarkers sends for a person; the bundled lookup lower-cases it.
+ const PERSON_IMAGE = 'Person_RespondingCall' as keyof typeof MAP_ICONS;
+
+ it('shows the role icon square, at the same size as other uploaded pins, keeping the lane ring', () => {
+ render();
+
+ const icon = screen.getByTestId('pin-type-icon');
+ expect(icon.props.source).toEqual({ uri: ROLE_ICON_URL });
+ expect(icon.props.contentFit).toBe('contain');
+ expect(icon).toHaveStyle({ width: 32, height: 32, borderColor: '#00aa00', borderRadius: 16 });
+ expect(screen.getByText('Alex Reed')).toBeTruthy();
+ expect(screen.getByTestId('pin-lane-label')).toBeTruthy();
+ expect(screen.UNSAFE_queryByType(Image)).toBeNull();
+ });
+
+ it('falls back to the bundled person pin when the role icon fails to load', () => {
+ render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-type-icon'), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ const bundled = screen.UNSAFE_getByType(Image);
+ expect(bundled.props.source).toBe(MAP_ICONS.person_respondingcall.uri);
+ // The lane ring moves to the bundled pin with it.
+ expect(StyleSheet.flatten(bundled.props.style)).toEqual(expect.objectContaining({ width: 32, height: 32, borderColor: '#00aa00', borderRadius: 16 }));
+ });
+
+ it('looks exactly as before when the person has no role icon', () => {
+ const { rerender } = render();
+
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.person_respondingcall.uri);
+
+ // Older servers omit the field altogether.
+ rerender();
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.person_respondingcall.uri);
+ });
+});
+
+describe('PinMarker status / priority pin (PinIconUrl)', () => {
+ // What GetMapDataAndMarkers sends for a responding engine: the bundled name plus the recoloured built-in pin.
+ const ENGINE_IMAGE = 'Engine_Responding' as keyof typeof MAP_ICONS;
+ const RESPONDING_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-responding?v=1';
+ const ONSCENE_PIN = 'https://api.resgrid.dev/api/v4/TypeIcons/Pins/engine-onscene?v=1';
+ const UPLOAD_URL = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/engine.png?s=responding&v=1';
+
+ it('draws the status pin in the bundled pin box, fit and lane ring, with no tint', () => {
+ const { unmount } = render();
+ const bundledStyle = StyleSheet.flatten(screen.UNSAFE_getByType(Image).props.style);
+ unmount();
+
+ render();
+
+ const statusPin = screen.getByTestId('pin-status-icon');
+ expect(statusPin.props.source).toEqual({ uri: RESPONDING_PIN });
+ expect(statusPin.props.contentFit).toBe('cover');
+ expect(statusPin.props.cachePolicy).toBe('memory-disk');
+ expect(statusPin.props.tintColor).toBeUndefined();
+ expect(StyleSheet.flatten(statusPin.props.style)).toEqual(bundledStyle);
+ expect(bundledStyle).toEqual(expect.objectContaining({ width: 32, height: 32, borderColor: '#ff0000', borderRadius: 16, resizeMode: 'cover' }));
+ expect(screen.getByTestId('pin-lane-label')).toBeTruthy();
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.UNSAFE_queryByType(Image)).toBeNull();
+ });
+
+ it('lets an uploaded icon win over the status pin, which wins over the bundled pin', () => {
+ render();
+
+ expect(screen.getByTestId('pin-type-icon').props.source).toEqual({ uri: UPLOAD_URL });
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+ expect(screen.UNSAFE_queryByType(Image)).toBeNull();
+
+ // A broken upload drops to the status pin, and a broken status pin to the bundled pin.
+ act(() => {
+ fireEvent(screen.getByTestId('pin-type-icon'), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.getByTestId('pin-status-icon').props.source).toEqual({ uri: RESPONDING_PIN });
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-status-icon'), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.engine_responding.uri);
+ });
+
+ it('falls back to the bundled pin, lane ring included, when the status pin fails to load', () => {
+ render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-status-icon'), 'error', { error: 'HTTP 500' });
+ });
+
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+ const bundled = screen.UNSAFE_getByType(Image);
+ expect(bundled.props.source).toBe(MAP_ICONS.engine_responding.uri);
+ expect(StyleSheet.flatten(bundled.props.style)).toEqual(expect.objectContaining({ width: 32, height: 32, borderColor: '#00aa00', borderRadius: 16 }));
+ });
+
+ it('redraws when only the status pin changes, and gives a new status pin a fresh attempt after a failure', () => {
+ const { rerender } = render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-status-icon'), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+
+ rerender();
+ expect(screen.getByTestId('pin-status-icon').props.source).toEqual({ uri: ONSCENE_PIN });
+
+ // The status has no meaning any more (or an older server): the bundled pin, exactly as before.
+ rerender();
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.engine_responding.uri);
+ });
+
+ it('retries a status pin that failed once its status comes back, but not while it is still the one asked for', () => {
+ const { rerender } = render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-status-icon'), 'error', { error: 'HTTP 500' });
+ });
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+
+ // Same status, same URL (a refetch hands over a new pin object): the known-bad pin is not hammered.
+ rerender();
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+
+ // On scene, then responding again: the transient failure is forgotten and the responding pin gets a fresh attempt.
+ rerender();
+ rerender();
+ expect(screen.getByTestId('pin-status-icon').props.source).toEqual({ uri: RESPONDING_PIN });
+
+ // Likewise after a spell with no status pin at all.
+ act(() => {
+ fireEvent(screen.getByTestId('pin-status-icon'), 'error', { error: 'HTTP 500' });
+ });
+ rerender();
+ rerender();
+ expect(screen.getByTestId('pin-status-icon').props.source).toEqual({ uri: RESPONDING_PIN });
+ });
+
+ it('retries an uploaded icon that failed once it is replaced and then restored', () => {
+ const { rerender } = render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('pin-type-icon'), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.queryByTestId('pin-type-icon')).toBeNull();
+ expect(screen.getByTestId('pin-status-icon').props.source).toEqual({ uri: RESPONDING_PIN });
+
+ rerender();
+ rerender();
+ expect(screen.getByTestId('pin-type-icon').props.source).toEqual({ uri: ICON_URL });
+ });
+
+ it.each(['/api/v4/TypeIcons/Pins/engine-responding?v=1', 'javascript:alert(1)', 'data:image/png;base64,AAAA', 'ftp://api.resgrid.dev/pin.png', ' '])(
+ 'ignores a status pin that is not an absolute http(s) URL (%s)',
+ (pinIconUrl) => {
+ render();
+
+ expect(screen.queryByTestId('pin-status-icon')).toBeNull();
+ expect(screen.UNSAFE_getByType(Image).props.source).toBe(MAP_ICONS.engine_responding.uri);
+ }
+ );
+});
+
+describe('getPinIconUrl', () => {
+ it('keeps absolute http(s) URLs, trimmed', () => {
+ expect(getPinIconUrl(' https://api.resgrid.dev/api/v4/TypeIcons/Pins/call-high?v=1 ')).toBe('https://api.resgrid.dev/api/v4/TypeIcons/Pins/call-high?v=1');
+ expect(getPinIconUrl('HTTP://localhost:5151/api/v4/TypeIcons/Pins/call-low?v=1')).toBe('HTTP://localhost:5151/api/v4/TypeIcons/Pins/call-low?v=1');
+ });
+
+ it('rejects empty, relative and non-http values', () => {
+ expect(getPinIconUrl(undefined)).toBeNull();
+ expect(getPinIconUrl(null)).toBeNull();
+ expect(getPinIconUrl('')).toBeNull();
+ expect(getPinIconUrl('https://')).toBeNull();
+ expect(getPinIconUrl('//api.resgrid.dev/pin.png')).toBeNull();
+ expect(getPinIconUrl('file:///pin.png')).toBeNull();
+ });
+});
diff --git a/src/components/maps/__tests__/poi-marker.test.tsx b/src/components/maps/__tests__/poi-marker.test.tsx
new file mode 100644
index 0000000..7a52011
--- /dev/null
+++ b/src/components/maps/__tests__/poi-marker.test.tsx
@@ -0,0 +1,49 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ return { Image: (props: any) => React.createElement('View', props) };
+});
+
+// The lucide glyph overlaid on the shape — stubbed so the test can tell the shape rendering apart.
+jest.mock('../poi-marker-icon', () => {
+ const React = require('react');
+ return { __esModule: true, default: (props: any) => React.createElement('View', { testID: 'poi-shape-icon', iconKey: props.iconKey }) };
+});
+
+import PoiMarker from '../poi-marker';
+
+const ICON_URL = 'https://api.resgrid.dev/api/v4/TypeIcons/hydrant-key';
+
+describe('PoiMarker type icon', () => {
+ it('shows the uploaded POI-type icon in place of the shape and glyph, in the same footprint', () => {
+ render();
+
+ const icon = screen.getByTestId('poi-type-icon');
+ expect(icon.props.source).toEqual({ uri: ICON_URL });
+ expect(icon.props.contentFit).toBe('contain');
+ expect(icon.props.cachePolicy).toBe('memory-disk');
+ expect(icon).toHaveStyle({ width: 36, height: 48 });
+ expect(screen.queryByTestId('poi-shape-icon')).toBeNull();
+ expect(screen.getByText('Hydrant 12')).toBeTruthy();
+ });
+
+ it('falls back to the shape and glyph when the uploaded icon fails to load', () => {
+ render();
+
+ act(() => {
+ fireEvent(screen.getByTestId('poi-type-icon'), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(screen.queryByTestId('poi-type-icon')).toBeNull();
+ expect(screen.getByTestId('poi-shape-icon')).toBeTruthy();
+ });
+
+ it('keeps the shape and glyph when there is no uploaded icon (older servers)', () => {
+ render();
+
+ expect(screen.queryByTestId('poi-type-icon')).toBeNull();
+ expect(screen.getByTestId('poi-shape-icon')).toBeTruthy();
+ });
+});
diff --git a/src/components/maps/map-legend-button.tsx b/src/components/maps/map-legend-button.tsx
new file mode 100644
index 0000000..1ff91c8
--- /dev/null
+++ b/src/components/maps/map-legend-button.tsx
@@ -0,0 +1,56 @@
+import { Info } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { StyleSheet, TouchableOpacity } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+interface MapLegendButtonProps {
+ onPress: () => void;
+ /** Distance from the bottom safe-area edge, so the button can stack with the map's other round controls. */
+ bottomOffset?: number;
+ testID?: string;
+}
+
+/**
+ * Round floating map control that opens the map legend; the same look as the main map's layers / recenter buttons.
+ */
+export const MapLegendButton: React.FC = ({ onPress, bottomOffset = 20, testID = 'map-legend-button' }) => {
+ const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
+ const insets = useSafeAreaInsets();
+ const isDark = colorScheme === 'dark';
+
+ return (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ position: 'absolute',
+ right: 20,
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ backgroundColor: '#3b82f6',
+ justifyContent: 'center',
+ alignItems: 'center',
+ elevation: 5,
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowRadius: 3.84,
+ },
+});
+
+export default MapLegendButton;
diff --git a/src/components/maps/map-legend-sheet.tsx b/src/components/maps/map-legend-sheet.tsx
new file mode 100644
index 0000000..03a8e50
--- /dev/null
+++ b/src/components/maps/map-legend-sheet.tsx
@@ -0,0 +1,384 @@
+import { RefreshCw } from 'lucide-react-native';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { getMapIconLegend } from '@/api/mapping/mapping';
+import { CustomBottomSheet } from '@/components/ui/bottom-sheet';
+import { Box } from '@/components/ui/box';
+import { Button, ButtonIcon, ButtonText } from '@/components/ui/button';
+import { Heading } from '@/components/ui/heading';
+import { HStack } from '@/components/ui/hstack';
+import { Spinner } from '@/components/ui/spinner';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { logger } from '@/lib/logging';
+import { type GetMapIconLegendResultData, type MapLegendPoiTypeData, type MapLegendRowData, type MapLegendStyleData } from '@/models/v4/mapping/getMapIconLegendResultData';
+import { TermKeys, useTerm } from '@/stores/terminology/store';
+
+import { PinMarkerIcon } from './pin-marker';
+import { PoiMarkerShape } from './poi-marker';
+
+// The sizes MapPins draws markers at, so a legend icon is the marker icon pixel for pixel.
+const PIN_ICON_SIZE = 32;
+const POI_ICON_SIZE = 36;
+
+// What GetMapDataAndMarkers sends as ImagePath for station and hydrant markers (neither has a type, status or upload).
+const STATION_IMAGE_PATH = 'Station';
+const HYDRANT_IMAGE_PATH = 'hydrant';
+
+// Style keys with a label of their own (maps.legend.style.*); "unknown" is labelled per kind, anything else shows its key.
+const STYLE_KEYS = new Set(['available', 'dispatched', 'responding', 'onscene', 'transporting', 'returning', 'staging', 'delayed', 'unavailable', 'low', 'medium', 'high', 'emergency']);
+
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
+
+/** The list, without null entries; anything that is not an array (a missing field) is an empty list. */
+function list(value: readonly (T | null | undefined)[] | null | undefined): T[] {
+ return Array.isArray(value) ? (value.filter((item) => item != null) as T[]) : [];
+}
+
+const joinNames = (names: readonly (string | null | undefined)[] | null | undefined): string =>
+ list(names)
+ .map((name) => name.trim())
+ .filter((name) => name.length > 0)
+ .join(', ');
+
+const validColor = (color?: string | null): string | null => {
+ const value = color?.trim();
+ return value && HEX_COLOR.test(value) ? value : null;
+};
+
+interface MapLegendSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** Show the hydrant entry: only when the map this legend explains draws hydrant markers. */
+ showHydrant?: boolean;
+}
+
+/**
+ * Map icon legend: every icon the department's map can show, fetched from the server each time the sheet opens so
+ * renamed types, custom priorities / statuses / status sets and new uploads show on the next open. Names come from the
+ * department; the app only adds its own labels for the style colours, the "none" rows and the section chrome.
+ */
+export const MapLegendSheet: React.FC = ({ isOpen, onClose, showHydrant = false }) => {
+ const { t } = useTranslation();
+ const [legend, setLegend] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [hasError, setHasError] = useState(false);
+ // Bumped by Retry: re-runs the fetch effect while the sheet stays open.
+ const [attempt, setAttempt] = useState(0);
+ const handleRetry = useCallback(() => setAttempt((value) => value + 1), []);
+
+ // Every open (and every retry) fetches afresh; closing or unmounting abandons a request still in flight.
+ useEffect(() => {
+ if (!isOpen) {
+ return undefined;
+ }
+
+ const controller = new AbortController();
+ setLegend(null);
+ setHasError(false);
+ setIsLoading(true);
+
+ getMapIconLegend(controller.signal)
+ .then((result) => {
+ if (!controller.signal.aborted) {
+ setLegend(result?.Data ?? {});
+ }
+ })
+ .catch((error) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+ logger.warn({ message: 'Map legend failed to load', context: { error } });
+ setHasError(true);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ });
+
+ return () => controller.abort();
+ }, [isOpen, attempt]);
+
+ return (
+
+
+ {t('maps.legend.title')}
+
+ {isLoading ? (
+
+
+ {t('common.loading')}
+
+ ) : hasError ? (
+
+ {t('maps.legend.error')}
+
+
+ ) : legend ? (
+
+ ) : null}
+
+
+ );
+};
+
+interface MapLegendContentProps {
+ legend: GetMapIconLegendResultData;
+ showHydrant: boolean;
+}
+
+const MapLegendContent: React.FC = ({ legend, showHydrant }) => {
+ const { t } = useTranslation();
+ // Module names follow the department's terminology (e.g. "Incidents" for calls), else the app's own words.
+ const callsTerm = useTerm(TermKeys.Calls, t('maps.legend.calls'));
+ const unitsTerm = useTerm(TermKeys.Units, t('maps.legend.units'));
+ const personnelTerm = useTerm(TermKeys.Personnel, t('maps.legend.personnel'));
+
+ const statusStyles = list(legend.StatusStyles);
+ const priorityStyles = list(legend.PriorityStyles);
+ const calls = list(legend.Calls);
+ const units = list(legend.Units);
+ const personnel = list(legend.Personnel);
+ const poiTypes = list(legend.PoiTypes);
+
+ const styleLabel = (key: string | null | undefined, kind: 'status' | 'priority'): string => {
+ const normalized = (key ?? '').trim().toLowerCase();
+ if (normalized === 'unknown') {
+ return kind === 'status' ? t('maps.legend.style.unknown_status') : t('maps.legend.style.unknown_priority');
+ }
+ return STYLE_KEYS.has(normalized) ? t(`maps.legend.style.${normalized}`) : (key ?? '');
+ };
+
+ const namesLine = (label: string, names: readonly (string | null | undefined)[] | null | undefined): string | null => {
+ const joined = joinNames(names);
+ return joined ? t('maps.legend.names_line', { label, names: joined }) : null;
+ };
+
+ return (
+
+ {statusStyles.length > 0 ? (
+
+ {statusStyles.map((style, index) => {
+ const label = styleLabel(style.Key, 'status');
+ const testID = `map-legend-status-style-${style.Key || index}`;
+ return (
+
+
+
+ >
+ }
+ />
+ );
+ })}
+
+ ) : null}
+
+ {priorityStyles.length > 0 ? (
+
+ {priorityStyles.map((style, index) => {
+ const label = styleLabel(style.Key, 'priority');
+ const testID = `map-legend-priority-style-${style.Key || index}`;
+ return (
+ }
+ />
+ );
+ })}
+
+ ) : null}
+
+ {calls.length > 0 ? (
+
+ {calls.map((row, index) => (
+
+ ))}
+
+ ) : null}
+
+ {units.length > 0 ? (
+
+ {units.map((row, index) => (
+
+ ))}
+
+ ) : null}
+
+ {personnel.length > 0 ? (
+
+ {personnel.map((row, index) => (
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+ {showHydrant ? (
+
+
+
+ ) : null}
+ {poiTypes.map((poiType, index) => (
+
+ ))}
+
+
+
+ );
+};
+
+interface LegendSectionProps {
+ title: string;
+ note?: string;
+ testID: string;
+ children: React.ReactNode;
+}
+
+const LegendSection: React.FC = ({ title, note, testID, children }) => (
+
+
+ {title}
+
+ {note ? {note} : null}
+ {children}
+
+);
+
+interface LegendStyleRowProps {
+ style: MapLegendStyleData;
+ label: string;
+ lines: (string | null)[];
+ icons: React.ReactNode;
+ testID: string;
+}
+
+const LegendStyleRow: React.FC = ({ style, label, lines, icons, testID }) => {
+ const color = validColor(style.Color);
+ return (
+
+
+ {icons}
+
+ {color ? : null}
+
+ {label}
+ {lines.map((line, index) =>
+ line ? (
+
+ {line}
+
+ ) : null
+ )}
+
+
+ );
+};
+
+interface LegendRowCardProps {
+ row: MapLegendRowData;
+ name: string;
+ subtitle?: string;
+ testID: string;
+}
+
+const LegendRowCard: React.FC = ({ row, name, subtitle, testID }) => {
+ const states = list(row.States);
+ return (
+
+
+
+
+ {name}
+ {subtitle ? {subtitle} : null}
+
+
+ {states.length > 0 ? (
+
+ {states.map((state, index) => {
+ const dotColor = validColor(state.Color);
+ const stateTestID = `${testID}-state-${state.Id}`;
+ return (
+ : null}
+ >
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+};
+
+interface LegendTileProps {
+ label: string;
+ dot?: React.ReactNode;
+ testID: string;
+ children: React.ReactNode;
+}
+
+const LegendTile: React.FC = ({ label, dot, testID, children }) => (
+
+ {children}
+
+ {dot}
+
+ {label}
+
+
+
+);
+
+interface LegendPoiTypeTileProps {
+ poiType: MapLegendPoiTypeData;
+}
+
+const LegendPoiTypeTile: React.FC = ({ poiType }) => {
+ const testID = `map-legend-poi-${poiType.PoiTypeId}`;
+ return (
+
+
+
+ );
+};
+
+export default MapLegendSheet;
diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx
index 6e60bae..d0f1c86 100644
--- a/src/components/maps/map-pins.tsx
+++ b/src/components/maps/map-pins.tsx
@@ -46,9 +46,24 @@ const MapPin = React.memo(({ pin, commandInfo, onPinPress }: { pin: MapMakerInfo
allowOverlap={true}
>
{poi ? (
-
+
) : (
-
+
)}
);
diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx
index 1f3cde0..55f7205 100644
--- a/src/components/maps/map-view.web.tsx
+++ b/src/components/maps/map-view.web.tsx
@@ -692,14 +692,35 @@ interface PointAnnotationProps {
onSelected?: () => void;
}
+/**
+ * Native anchors are fractional {x, y} offsets into the marker box; mapbox-gl takes a named anchor instead
+ * ('center', 'bottom', 'top-left', ...). Measuring the element to turn the fraction into a pixel offset (the
+ * previous approach) always read 0×0, because the marker's React root has not rendered when the marker is
+ * created — so every pin ended up centered on its coordinate on web while native sat it on its bottom edge.
+ * A string passes straight through (it is already a mapbox-gl anchor).
+ */
+export function toMarkerAnchor(anchor: string | { x: number; y: number } | undefined | null): string {
+ if (typeof anchor === 'string') return anchor;
+ if (!anchor || typeof anchor.x !== 'number' || typeof anchor.y !== 'number') return 'center';
+
+ const vertical = anchor.y >= 0.75 ? 'bottom' : anchor.y <= 0.25 ? 'top' : '';
+ const horizontal = anchor.x >= 0.75 ? 'right' : anchor.x <= 0.25 ? 'left' : '';
+
+ if (vertical && horizontal) return `${vertical}-${horizontal}`;
+ return vertical || horizontal || 'center';
+}
+
// PointAnnotation component
export const PointAnnotation: React.FC = ({ id, coordinate, title, children, anchor = { x: 0.5, y: 0.5 }, onSelected }) => {
const map = useContext(MapContext);
const markerRef = useRef(null);
const containerRef = useRef(null);
const containerRootRef = useRef(null);
+ // mapbox-gl only takes the anchor when the marker is constructed, so a changed anchor rebuilds the marker. Keyed on
+ // the named anchor (a string), not the anchor object, which callers allocate afresh on every render.
+ const markerAnchor = toMarkerAnchor(anchor);
- // Create marker once when map/id are available
+ // Create marker when map/id/anchor are available
useEffect(() => {
if (!map || !coordinate) return;
@@ -710,21 +731,10 @@ export const PointAnnotation: React.FC = ({ id, coordinate
const root = createRoot(container);
containerRootRef.current = root;
- const markerOptions: any = { element: container };
-
- if (typeof anchor === 'string') {
- markerOptions.anchor = anchor as any;
- }
+ const markerOptions: any = { element: container, anchor: markerAnchor };
markerRef.current = new mapboxgl.Marker(markerOptions).setLngLat(coordinate).addTo(map);
- if (typeof anchor === 'object' && anchor !== null && 'x' in anchor && 'y' in anchor) {
- const rect = container.getBoundingClientRect();
- const xOffset = (anchor.x - 0.5) * rect.width;
- const yOffset = (anchor.y - 0.5) * rect.height;
- markerRef.current.setOffset([xOffset, yOffset]);
- }
-
if (title) {
markerRef.current.setPopup(new mapboxgl.Popup().setText(title));
}
@@ -739,7 +749,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate
containerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [map, id]);
+ }, [map, id, markerAnchor]);
// Update coordinate when it changes
useEffect(() => {
@@ -749,14 +759,14 @@ export const PointAnnotation: React.FC = ({ id, coordinate
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [coordinate?.[0], coordinate?.[1]]);
- // Render children into the marker's React root
+ // Render children into the marker's React root (again into a rebuilt marker's fresh root)
useEffect(() => {
if (containerRootRef.current && children) {
containerRootRef.current.render(<>{children}>);
}
- }, [children]);
+ }, [children, map, id, markerAnchor]);
- // Update click handler
+ // Update click handler (and bind it to a rebuilt marker's new element)
useEffect(() => {
const container = containerRef.current;
if (!container || !onSelected) return;
@@ -765,7 +775,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate
return () => {
container.removeEventListener('click', onSelected);
};
- }, [onSelected]);
+ }, [onSelected, map, id, markerAnchor]);
return null;
};
@@ -815,14 +825,24 @@ export const UserLocation: React.FC<{ visible?: boolean; showsUserHeadingIndicat
return null;
};
+interface MarkerViewProps {
+ id?: string;
+ coordinate: [number, number];
+ children?: React.ReactNode;
+ /** Same fractional anchor as native MarkerView ({ x: 0.5, y: 1 } = bottom-center on the coordinate); default center. */
+ anchor?: { x: number; y: number };
+ /** Native-only collision flag; DOM markers always overlap, so it is accepted and ignored here. */
+ allowOverlap?: boolean;
+}
+
// MarkerView component
-export const MarkerView: React.FC<{ id?: string; coordinate: [number, number]; children?: React.ReactNode }> = ({ id, coordinate, children }) => {
+export const MarkerView: React.FC = ({ id, coordinate, children, anchor }) => {
// The id keys the underlying mapbox-gl marker, so it has to stay stable while the marker moves:
// PointAnnotation then just calls setLngLat. An id derived from the coordinate tore the marker
// (and its React root) down and rebuilt it on every live position update.
const fallbackId = useId();
return (
-
+
{children}
);
diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx
index b533408..aa4d6a3 100644
--- a/src/components/maps/pin-marker.tsx
+++ b/src/components/maps/pin-marker.tsx
@@ -1,6 +1,7 @@
+import { Image as ExpoImage } from 'expo-image';
import { useColorScheme } from 'nativewind';
-import React from 'react';
-import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native';
+import React, { useCallback, useState } from 'react';
+import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { PointAnnotation } from '@/components/maps/mapbox';
import { MAP_ICONS } from '@/constants/map-icons';
@@ -10,6 +11,17 @@ type MapIconKey = keyof typeof MAP_ICONS;
interface PinMarkerProps {
imagePath?: MapIconKey;
poiImage?: MapIconKey;
+ /**
+ * Uploaded icon URL — the call/unit type's icon, or for a personnel pin the person's role icon; shown in place of
+ * the bundled icon when it loads.
+ */
+ iconUrl?: string | null;
+ /**
+ * Built-in pin recoloured to the call's priority or the unit's / person's status (`MapMakerInfoData.PinIconUrl`).
+ * Drawn exactly where and how the bundled pin is when there is no (working) uploaded icon; the bundled pin comes
+ * back if it fails to load. Only absolute http(s) URLs are used.
+ */
+ pinIconUrl?: string | null;
title: string;
/** Command lane name — shown under the title when the resource is on the active board. */
laneLabel?: string;
@@ -20,18 +32,114 @@ interface PinMarkerProps {
onPress?: () => void;
}
-const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, laneLabel, accentColor, size = 32, onPress }) => {
- const { colorScheme } = useColorScheme();
+/** The status / priority pin URL when it is an absolute http(s) URL, else null (blank, relative or other schemes). */
+export const getPinIconUrl = (pinIconUrl?: string | null): string | null => {
+ const url = pinIconUrl?.trim();
+ return url && /^https?:\/\/\S+$/i.test(url) ? url : null;
+};
+
+interface PinMarkerIconProps {
+ imagePath?: MapIconKey | string | null;
+ poiImage?: MapIconKey | string | null;
+ iconUrl?: string | null;
+ pinIconUrl?: string | null;
+ accentColor?: string | null;
+ size?: number;
+ /**
+ * False: when there is no (working) uploaded icon or status pin, keep the box empty instead of drawing the bundled
+ * icon. For a legend swatch that has no bundled counterpart (a status style's default pin); map markers always fall back.
+ */
+ fallbackToBundled?: boolean;
+ /** Screen-reader label for the icon; map markers leave it unset (their title is the label). */
+ accessibilityLabel?: string;
+ /**
+ * Test id base: the image gets `${testID}-upload`, `${testID}-pin`, `${testID}-bundled` or `${testID}-empty`. Without it
+ * the upload and status pin keep the marker's `pin-type-icon` / `pin-status-icon`.
+ */
+ testID?: string;
+}
+/**
+ * The icon of a call / unit / station / person marker, without its title: the uploaded icon (`IconUrl`), else the
+ * status / priority pin (`PinIconUrl`), else the bundled `ImagePath` asset, each failed remote image falling back down
+ * the chain. The map's markers and the map legend both draw through this, so a legend icon is exactly the marker icon.
+ */
+export const PinMarkerIcon: React.FC = React.memo(({ imagePath, poiImage, iconUrl, pinIconUrl, accentColor, size = 32, fallbackToBundled = true, accessibilityLabel, testID }) => {
// Prefer poiImage (new field) over imagePath (null for POIs after backend fix),
// with final fallback to default 'call' icon
const resolvedPath = poiImage || imagePath;
const iconKey = resolvedPath?.toLowerCase() as MapIconKey;
const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['call'];
+ const ringStyle = accentColor ? { ...styles.accentRing, borderColor: accentColor, borderRadius: size / 2 } : null;
+ const a11y = accessibilityLabel ? { accessible: true, accessibilityLabel, accessibilityRole: 'image' as const } : null;
+
+ // Uploaded type / role icon: same square footprint as the bundled pin, and back to the status pin (else the bundled
+ // pin) if it fails to load. Remember which URL failed (not a bare flag) so a changed icon gets a fresh attempt.
+ const [failedIconUrl, setFailedIconUrl] = useState(null);
+ const remoteIconUrl = iconUrl?.trim() || null;
+ const showUploadedIcon = !!remoteIconUrl && remoteIconUrl !== failedIconUrl;
+
+ // Status / priority pin: the bundled pin's image recoloured server side, so it takes the bundled pin's exact box,
+ // fit and lane ring (and no tint). Same failed-URL memory, so the next status's pin is tried afresh.
+ const [failedPinIconUrl, setFailedPinIconUrl] = useState(null);
+ const statusPinUrl = getPinIconUrl(pinIconUrl);
+ const showStatusPin = !showUploadedIcon && !!statusPinUrl && statusPinUrl !== failedPinIconUrl;
+
+ // A failure is only remembered while that URL is still the one asked for: once it changes (a new status, or no pin
+ // at all) the failure is dropped, so a URL that failed transiently (404 / 500 / offline) is retried when it returns.
+ if (failedIconUrl !== null && failedIconUrl !== remoteIconUrl) {
+ setFailedIconUrl(null);
+ }
+ if (failedPinIconUrl !== null && failedPinIconUrl !== statusPinUrl) {
+ setFailedPinIconUrl(null);
+ }
+
+ const handleIconError = useCallback(() => setFailedIconUrl(remoteIconUrl), [remoteIconUrl]);
+ const handlePinIconError = useCallback(() => setFailedPinIconUrl(statusPinUrl), [statusPinUrl]);
+
+ if (showUploadedIcon && remoteIconUrl) {
+ return (
+
+ );
+ }
+
+ if (showStatusPin && statusPinUrl) {
+ return (
+
+ );
+ }
+
+ if (!fallbackToBundled) {
+ return ;
+ }
+
+ return ;
+});
+
+PinMarkerIcon.displayName = 'PinMarkerIcon';
+
+const PinMarker: React.FC = React.memo(({ imagePath, poiImage, iconUrl, pinIconUrl, title, laneLabel, accentColor, size = 32, onPress }) => {
+ const { colorScheme } = useColorScheme();
return (
-
+
{title}
diff --git a/src/components/maps/poi-marker.tsx b/src/components/maps/poi-marker.tsx
index 1533388..c77340e 100644
--- a/src/components/maps/poi-marker.tsx
+++ b/src/components/maps/poi-marker.tsx
@@ -1,5 +1,6 @@
+import { Image as ExpoImage } from 'expo-image';
import { useColorScheme } from 'nativewind';
-import React from 'react';
+import React, { useCallback, useState } from 'react';
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
@@ -15,6 +16,8 @@ export interface PoiMarkerProps {
poiImage?: string;
/** Legacy ImagePath field (null for POIs, but used as fallback) */
imagePath?: string;
+ /** Uploaded POI-type icon URL; shown in place of the shape + icon when it loads. */
+ iconUrl?: string | null;
/** The hex color for the SVG shape fill, e.g. "#2563eb" */
color?: string;
/** The marker shape type, e.g. "MAP_PIN", "SHIELD" */
@@ -40,11 +43,43 @@ export interface PoiMarkerProps {
* - White icon centered horizontally, 10px from top
* - Title label below the marker
*/
-const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, color, marker, title, size = 36, onPress }) => {
+const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, iconUrl, color, marker, title, size = 36, onPress }) => {
+ return (
+
+
+
+
+ );
+});
+
+PoiMarker.displayName = 'PoiMarker';
+
+/**
+ * Props for the PoiMarkerShape component.
+ */
+export interface PoiMarkerShapeProps {
+ poiImage?: string | null;
+ imagePath?: string | null;
+ iconUrl?: string | null;
+ color?: string | null;
+ marker?: string | null;
+ /** Overall shape width in pixels; height scales proportionally (36 → 48). */
+ size?: number;
+ /** Screen-reader label for the icon; map markers leave it unset (their title is the label). */
+ accessibilityLabel?: string;
+ /** Test id base: `${testID}-upload` on the uploaded icon, `${testID}-shape` on the shape. Default: `poi-type-icon` on the upload. */
+ testID?: string;
+}
+
+/**
+ * The POI marker's icon, without its title: the uploaded POI-type icon, else the coloured shape with the white glyph
+ * (a failed upload falls back to the shape). The map's POI markers and the map legend both draw through this.
+ */
+export const PoiMarkerShape: React.FC = React.memo(({ poiImage, imagePath, iconUrl, color, marker, size = 36, accessibilityLabel, testID }) => {
// Resolve rendering properties (with defaults per the reference document)
const shapePath = getPoiMarkerShapePath(marker);
const fillColor = getPoiMarkerColor(color);
- const iconClass = getPoiMarkerIconClass({ PoiImage: poiImage, ImagePath: imagePath });
+ const iconClass = getPoiMarkerIconClass({ PoiImage: poiImage ?? undefined, ImagePath: imagePath ?? undefined });
const iconKey = getMapIconKey(iconClass);
// Height scales proportionally: width 36 → height 48, so height = width * (48/36) = width * 4/3
@@ -55,38 +90,53 @@ const PoiMarker: React.FC = React.memo(({ poiImage, imagePath, c
const iconTop = (POI_ICON_LAYOUT.topOffset / POI_MARKER_DIMENSIONS.height) * svgHeight;
const iconFontSize = (POI_ICON_LAYOUT.fontSize / POI_MARKER_DIMENSIONS.width) * svgWidth;
- return (
-
- {/* SVG background shape with drop shadow */}
-
-
-
- {/* White icon overlaid on the shape */}
-
-
-
+ // Uploaded POI-type icon replaces the shape + icon in the same footprint; if it fails to load, the shape
+ // comes back. Remember which URL failed (not a bare flag) so a changed icon gets a fresh attempt.
+ const [failedIconUrl, setFailedIconUrl] = useState(null);
+ const remoteIconUrl = iconUrl?.trim() || null;
+ const a11y = accessibilityLabel ? { accessible: true, accessibilityLabel, accessibilityRole: 'image' as const } : null;
+ const handleIconError = useCallback(() => setFailedIconUrl(remoteIconUrl), [remoteIconUrl]);
+
+ return remoteIconUrl && remoteIconUrl !== failedIconUrl ? (
+
+
+
+ ) : (
+ /* SVG background shape with drop shadow */
+
+
+
+ {/* White icon overlaid on the shape */}
+
+
-
-
+
);
});
-PoiMarker.displayName = 'PoiMarker';
+PoiMarkerShape.displayName = 'PoiMarkerShape';
/**
* Renders the title label below the POI marker shape.
diff --git a/src/components/units/__tests__/unit-type-icon.test.tsx b/src/components/units/__tests__/unit-type-icon.test.tsx
new file mode 100644
index 0000000..17ac774
--- /dev/null
+++ b/src/components/units/__tests__/unit-type-icon.test.tsx
@@ -0,0 +1,106 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+import { Text } from 'react-native';
+
+jest.mock('expo-image', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return {
+ Image: (props: any) => React.createElement(View, props),
+ };
+});
+
+import { MAP_ICONS } from '@/constants/map-icons';
+
+import { getUnitTypeMapIconAsset, UnitTypeIcon } from '../unit-type-icon';
+
+const ICON_URL = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/abc.png';
+
+// The icon is decorative (hidden from assistive tech), so default queries skip it.
+const getIcon = () => screen.getByTestId('type-icon', { includeHiddenElements: true });
+const queryIcon = () => screen.queryByTestId('type-icon', { includeHiddenElements: true });
+
+describe('UnitTypeIcon', () => {
+ it('renders the uploaded icon when a URL is given', () => {
+ render();
+
+ const image = getIcon();
+ expect(image.props.source).toEqual({ uri: ICON_URL });
+ expect(image.props.contentFit).toBe('contain');
+ expect(image.props.cachePolicy).toBe('memory-disk');
+ expect(image.props.style).toEqual({ width: 20, height: 20 });
+ // Decorative: the unit name beside it carries the meaning.
+ expect(image.props.accessible).toBe(false);
+ });
+
+ it('falls back to the bundled built-in icon when there is no upload, matching the name case-insensitively', () => {
+ render();
+
+ const image = getIcon();
+ expect(image.props.source).toBe(MAP_ICONS.ambulance.uri);
+ expect(image.props.contentFit).toBe('contain');
+ expect(image.props.style).toEqual({ width: 24, height: 24 });
+ expect(image.props.onError).toBeUndefined();
+ });
+
+ it('renders the fallback when the type has no icon or an unknown built-in name', () => {
+ const { rerender } = render(T} testID="type-icon" />);
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ expect(queryIcon()).toBeNull();
+
+ rerender(T} testID="type-icon" />);
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ expect(queryIcon()).toBeNull();
+ });
+
+ it('renders nothing by default when there is no icon (older servers omit the fields)', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+
+ it('falls to the built-in icon when the uploaded icon fails to load', () => {
+ render(T} testID="type-icon" />);
+
+ act(() => {
+ fireEvent(getIcon(), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(getIcon().props.source).toBe(MAP_ICONS.cartwo.uri);
+ expect(screen.queryByTestId('fallback')).toBeNull();
+ });
+
+ it('falls to the fallback when the uploaded icon fails to load and there is no built-in icon', () => {
+ render(T} testID="type-icon" />);
+
+ act(() => {
+ fireEvent(getIcon(), 'error', { error: 'HTTP 404' });
+ });
+
+ expect(queryIcon()).toBeNull();
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+ });
+
+ it('retries the upload when the unit is given a different icon URL after a failure', () => {
+ const { rerender } = render(T} testID="type-icon" />);
+
+ act(() => {
+ fireEvent(getIcon(), 'error', { error: 'HTTP 404' });
+ });
+ expect(screen.getByTestId('fallback')).toBeTruthy();
+
+ const nextUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/def.png';
+ rerender(T} testID="type-icon" />);
+ expect(getIcon().props.source).toEqual({ uri: nextUrl });
+ });
+});
+
+describe('getUnitTypeMapIconAsset', () => {
+ it('resolves known names in any case and rejects unknown or empty ones', () => {
+ expect(getUnitTypeMapIconAsset('AMBULANCE')).toBe(MAP_ICONS.ambulance.uri);
+ expect(getUnitTypeMapIconAsset(' truck ')).toBe(MAP_ICONS.truck.uri);
+ expect(getUnitTypeMapIconAsset('toString')).toBeNull();
+ expect(getUnitTypeMapIconAsset('')).toBeNull();
+ expect(getUnitTypeMapIconAsset(null)).toBeNull();
+ expect(getUnitTypeMapIconAsset(undefined)).toBeNull();
+ });
+});
diff --git a/src/components/units/unit-type-icon.tsx b/src/components/units/unit-type-icon.tsx
new file mode 100644
index 0000000..a2202cf
--- /dev/null
+++ b/src/components/units/unit-type-icon.tsx
@@ -0,0 +1,63 @@
+import { Image } from 'expo-image';
+import React, { useCallback, useState } from 'react';
+
+import { MAP_ICONS } from '@/constants/map-icons';
+
+type MapIconKey = keyof typeof MAP_ICONS;
+
+/** The type-icon fields the v4 unit payloads carry (all optional: older servers omit them). */
+export interface UnitTypeIconSource {
+ iconUrl?: string | null;
+ mapIcon?: string | null;
+}
+
+export interface UnitTypeIconProps extends UnitTypeIconSource {
+ /** Square box edge in px; both the uploaded icon and the bundled pin are fitted (contain) inside it. */
+ size?: number;
+ /** Rendered when the type has no usable icon (no upload or the upload failed to load, and no known built-in). */
+ fallback?: React.ReactNode;
+ testID?: string;
+}
+
+/** Bundled asset for a built-in map icon name (any case), or null when the name is empty or unknown. */
+export const getUnitTypeMapIconAsset = (mapIcon?: string | null) => {
+ const key = mapIcon?.trim().toLowerCase();
+ return key && Object.prototype.hasOwnProperty.call(MAP_ICONS, key) ? MAP_ICONS[key as MapIconKey].uri : null;
+};
+
+/**
+ * Icon a department picked for a unit, call or POI type: its uploaded image when there is one, else the
+ * built-in map icon it chose, else `fallback`. Purely decorative — the name next to it carries the meaning.
+ */
+export const UnitTypeIcon: React.FC = ({ iconUrl, mapIcon, size = 24, fallback = null, testID }) => {
+ // Remember which URL failed rather than a bare flag, so a unit whose icon changes gets a fresh attempt.
+ const [failedUrl, setFailedUrl] = useState(null);
+ const remoteUrl = iconUrl?.trim() || null;
+ const showRemote = !!remoteUrl && remoteUrl !== failedUrl;
+ const bundled = getUnitTypeMapIconAsset(mapIcon);
+ const handleRemoteError = useCallback(() => setFailedUrl(remoteUrl), [remoteUrl]);
+
+ const source = showRemote && remoteUrl ? { uri: remoteUrl } : bundled;
+ if (!source) {
+ return <>{fallback}>;
+ }
+
+ return (
+
+ );
+};
+
+/** Same component under a type-neutral name, for call types, POI types and personnel role icons. */
+export const TypeIcon = UnitTypeIcon;
+
+export default UnitTypeIcon;
diff --git a/src/lib/__tests__/live-locations.test.ts b/src/lib/__tests__/live-locations.test.ts
index 0ff8e5f..20e667d 100644
--- a/src/lib/__tests__/live-locations.test.ts
+++ b/src/lib/__tests__/live-locations.test.ts
@@ -164,6 +164,20 @@ describe('applyLiveLocations', () => {
expect(result.unknownPinIds).toEqual([]);
});
+ it('keeps the uploaded icon on a moved pin (personnel role icon, unit type icon)', () => {
+ const roleIconUrl = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
+ const unitIconUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/engine.png';
+ const iconPins = [
+ { ...unit, IconUrl: unitIconUrl },
+ { ...person, IconUrl: roleIconUrl },
+ ];
+
+ const result = applyLiveLocations(iconPins, { u12: live('u12', 10.5, 20.5), [`p${GUID}`]: live(`p${GUID}`, 11.5, 21.5) });
+
+ expect(result.pins[0]).toEqual(expect.objectContaining({ IconUrl: unitIconUrl, Latitude: 10.5 }));
+ expect(result.pins[1]).toEqual(expect.objectContaining({ Type: 3, IconUrl: roleIconUrl, Latitude: 11.5, Longitude: 21.5 }));
+ });
+
it('never adds a pin and reports it as unknown instead', () => {
const result = applyLiveLocations(pins, { u99: live('u99', 1, 1) });
diff --git a/src/models/v4/callTypes/callTypeResultData.ts b/src/models/v4/callTypes/callTypeResultData.ts
index 2829396..bea1166 100644
--- a/src/models/v4/callTypes/callTypeResultData.ts
+++ b/src/models/v4/callTypes/callTypeResultData.ts
@@ -3,4 +3,8 @@ export class CallTypeResultData {
public Name: string = '';
/** What kind of call the type is (CallTypeClasses, see lib/base-types); -1 when not set, undefined from older servers. */
public BaseType?: number | null;
+ /** Absolute URL of the type's uploaded icon (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public IconUrl?: string | null = null;
+ /** Built-in map icon name the type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public MapIcon?: string | null = null;
}
diff --git a/src/models/v4/calls/callResultData.ts b/src/models/v4/calls/callResultData.ts
index 660a74c..4f2fa98 100644
--- a/src/models/v4/calls/callResultData.ts
+++ b/src/models/v4/calls/callResultData.ts
@@ -32,6 +32,10 @@ export class CallResultData {
public Type: string = 'No Type';
/** Id of the department call type the call was logged with (survives a rename); null when it matches none, undefined from older servers. */
public TypeId?: string | null;
+ /** Absolute URL of the department's uploaded icon for the call's type (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the call's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
public LoggedOnUtc: string = '';
public DispatchedOn: string = '';
public DispatchedOnUtc: string = '';
diff --git a/src/models/v4/calls/dispatchedEventResultData.ts b/src/models/v4/calls/dispatchedEventResultData.ts
index 36ff38f..d60e61e 100644
--- a/src/models/v4/calls/dispatchedEventResultData.ts
+++ b/src/models/v4/calls/dispatchedEventResultData.ts
@@ -11,4 +11,8 @@ export class DispatchedEventResultData {
public StatusText: string = '';
public StatusColor: string = '';
+ /** Absolute URL of the department's uploaded icon for this unit's type (unit rows only) (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the unit's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
}
diff --git a/src/models/v4/mapping/getMapDataAndMarkersData.ts b/src/models/v4/mapping/getMapDataAndMarkersData.ts
index d3dc477..6aeb4cd 100644
--- a/src/models/v4/mapping/getMapDataAndMarkersData.ts
+++ b/src/models/v4/mapping/getMapDataAndMarkersData.ts
@@ -26,4 +26,22 @@ export class MapMakerInfoData {
public Note?: string = '';
public LayerId?: string = '';
public LayerName?: string = '';
+ /**
+ * Uploaded icon URL — wins over the bundled icon / POI shape when present: the type's icon for call (Type 0), unit
+ * (Type 1) and POI (Type 4) pins; for personnel (Type 3) pins the icon of the first role the person holds, in the
+ * department's role order, that has one. Null when there is no upload, undefined from older servers.
+ */
+ public IconUrl?: string | null = null;
+ /**
+ * Absolute URL of the built-in pin recoloured to the call's priority or the unit's / person's status: a 32 x 37 PNG
+ * with the bundled pin's shape, size and tip, drawn in place of the bundled `ImagePath` pin when there is no upload.
+ * Null when the priority/status has no meaning and always for stations, POIs and hydrants; undefined from older servers.
+ */
+ public PinIconUrl?: string | null = null;
+ /**
+ * Style key behind `PinIconUrl` and the badge on `IconUrl`: a status (`available`, `dispatched`, `responding`, `onscene`,
+ * `transporting`, `returning`, `staging`, `delayed`, `unavailable`) or a priority (`low`, `medium`, `high`, `emergency`).
+ * Null / undefined as for `PinIconUrl`.
+ */
+ public IconStyle?: string | null = null;
}
diff --git a/src/models/v4/mapping/getMapIconLegendResult.ts b/src/models/v4/mapping/getMapIconLegendResult.ts
new file mode 100644
index 0000000..b67efe3
--- /dev/null
+++ b/src/models/v4/mapping/getMapIconLegendResult.ts
@@ -0,0 +1,6 @@
+import { BaseV4Request } from '../baseV4Request';
+import { GetMapIconLegendResultData } from './getMapIconLegendResultData';
+
+export class GetMapIconLegendResult extends BaseV4Request {
+ public Data: GetMapIconLegendResultData = new GetMapIconLegendResultData();
+}
diff --git a/src/models/v4/mapping/getMapIconLegendResultData.ts b/src/models/v4/mapping/getMapIconLegendResultData.ts
new file mode 100644
index 0000000..011bcc4
--- /dev/null
+++ b/src/models/v4/mapping/getMapIconLegendResultData.ts
@@ -0,0 +1,88 @@
+/**
+ * A status style (unit / personnel colours) or priority style (call colours) of the map legend.
+ * `Key` is the style key map markers carry in `IconStyle`; the names are the department's own.
+ */
+export class MapLegendStyleData {
+ /** available | dispatched | responding | onscene | transporting | returning | staging | delayed | unavailable | low | medium | high | emergency | unknown */
+ public Key: string = '';
+ /** "#RRGGBB" */
+ public Color: string = '';
+ /** ActionBaseTypes the status style groups; not shown. */
+ public Meanings?: number[] = [];
+ /** Status styles: the department's unit status names (every status set) that show in this style. */
+ public UnitStatuses?: string[] = [];
+ /** Status styles: the department's personnel status names that show in this style. */
+ public PersonnelStatuses?: string[] = [];
+ /** Priority styles: the department's priority names that show in this style. */
+ public Priorities?: string[] = [];
+ /** Status styles: the default unit pin in this style (absolute URL, 32 x 37). */
+ public UnitPinIconUrl?: string | null = null;
+ /** Status styles: the default person pin in this style. */
+ public PersonPinIconUrl?: string | null = null;
+ /** Priority styles: the default call pin in this style. */
+ public CallPinIconUrl?: string | null = null;
+}
+
+/** One priority (calls) or status (units, personnel) of a legend row, with the icon a marker of the row shows in it. */
+export class MapLegendStateData {
+ /** The stored priority id / status id. */
+ public Id: number = 0;
+ /** The department's own name for the priority / status. */
+ public Name: string = '';
+ /** The priority's / status's own hex colour, or null. */
+ public Color?: string | null = null;
+ /** Style key ("unknown" when the priority / status has no meaning). */
+ public IconStyle?: string | null = null;
+ /** Upload for this exact state, or the type's own upload with a badge; null = none. */
+ public IconUrl?: string | null = null;
+ /** Styled built-in pin (absolute URL): the fallback when `IconUrl` is null or fails. */
+ public PinIconUrl?: string | null = null;
+ /** Bundled fallback icon name. */
+ public ImagePath?: string | null = null;
+ /** True when `IconUrl` was uploaded for this exact state. */
+ public IsVariant?: boolean = false;
+}
+
+/** A call type, unit type or role, or the "none" row (`Id` null), with its icon in each priority / status. */
+export class MapLegendRowData {
+ /** Null for the "none" row: show the app's own localized label, not `Name`. */
+ public Id: number | null = null;
+ /** The department's name for the call type / unit type / role. */
+ public Name: string = '';
+ /** Units: the type's custom status set name; null = the built-in unit statuses. */
+ public StatusSetName?: string | null = null;
+ /** The type's / role's own uploaded icon (no status), or null. */
+ public IconUrl?: string | null = null;
+ /** Always null for rows; present so a row reads like a marker. */
+ public PinIconUrl?: string | null = null;
+ /** The bundled icon the type picked, or the default call / unit / person icon. */
+ public ImagePath?: string | null = null;
+ public States?: MapLegendStateData[] = [];
+}
+
+/** A department POI type and the icon its markers show (same fields as PoiLayerData / POI markers). */
+export class MapLegendPoiTypeData {
+ public PoiTypeId: number = 0;
+ public Name: string = '';
+ public Color?: string | null = null;
+ public PoiImage?: string | null = null;
+ public ImagePath?: string | null = null;
+ public Marker?: string | null = null;
+ public IconUrl?: string | null = null;
+}
+
+/** `GET /api/v4/Mapping/GetMapIconLegend` payload. Every list may be missing from older / partial responses. */
+export class GetMapIconLegendResultData {
+ /** 9 status styles in legend order, then "unknown". */
+ public StatusStyles?: MapLegendStyleData[] = [];
+ /** low, medium, high, emergency, then "unknown". */
+ public PriorityStyles?: MapLegendStyleData[] = [];
+ /** First row (Id null) = calls with no type, then call types by name. */
+ public Calls?: MapLegendRowData[] = [];
+ /** First row (Id null) = units with no type, then unit types by name. */
+ public Units?: MapLegendRowData[] = [];
+ /** First row (Id null) = members with no role icon, then roles with an uploaded icon, in role order. */
+ public Personnel?: MapLegendRowData[] = [];
+ /** Department POI types by name. */
+ public PoiTypes?: MapLegendPoiTypeData[] = [];
+}
diff --git a/src/models/v4/mapping/poiResultData.ts b/src/models/v4/mapping/poiResultData.ts
index d9a1c5f..3dec83b 100644
--- a/src/models/v4/mapping/poiResultData.ts
+++ b/src/models/v4/mapping/poiResultData.ts
@@ -6,6 +6,8 @@ export class PoiTypeResultData {
public PoiImage: string = '';
public Marker: string = '';
public IsDestination: boolean = false;
+ /** Absolute URL of the type's uploaded icon (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public IconUrl?: string | null = null;
}
export class PoiResultData {
@@ -21,6 +23,8 @@ export class PoiResultData {
public ImagePath: string = '';
public Marker: string = '';
public IsDestination: boolean = false;
+ /** Absolute URL of the POI type's uploaded icon (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public IconUrl?: string | null = null;
}
export class PoiLayerData {
@@ -31,4 +35,6 @@ export class PoiLayerData {
public PoiImage: string = '';
public Marker: string = '';
public IsDestination: boolean = false;
+ /** Absolute URL of the POI type's uploaded icon (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public IconUrl?: string | null = null;
}
diff --git a/src/models/v4/messages/recipientsResultData.ts b/src/models/v4/messages/recipientsResultData.ts
index fd4cec5..190ab02 100644
--- a/src/models/v4/messages/recipientsResultData.ts
+++ b/src/models/v4/messages/recipientsResultData.ts
@@ -3,4 +3,8 @@ export class RecipientsResultData {
public Type: string = '';
public Name: string = '';
public Selected: boolean = false;
+ /** Absolute URL of the department's uploaded icon for this unit's type (Type "Unit" rows only) (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the unit's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
}
diff --git a/src/models/v4/personnel/personnelInfoResultData.ts b/src/models/v4/personnel/personnelInfoResultData.ts
index b6b5d2b..0e402a1 100644
--- a/src/models/v4/personnel/personnelInfoResultData.ts
+++ b/src/models/v4/personnel/personnelInfoResultData.ts
@@ -25,4 +25,9 @@ export class PersonnelInfoResultData {
public StaffingBaseType?: number | null;
public StaffingTimestamp: string = '';
public Roles: string[] = [];
+ /**
+ * Absolute URL of the uploaded icon of the first role this person holds (in the department's role order) that has
+ * one — the same icon as their map pin (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers.
+ */
+ public RoleIconUrl?: string | null = null;
}
diff --git a/src/models/v4/roles/roleResultData.ts b/src/models/v4/roles/roleResultData.ts
index 90d626f..84c1e51 100644
--- a/src/models/v4/roles/roleResultData.ts
+++ b/src/models/v4/roles/roleResultData.ts
@@ -1,4 +1,8 @@
export class RoleResultData {
public RoleId: string = '';
public Name: string = '';
+ /** Absolute URL of the role's uploaded icon (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public IconUrl?: string | null = null;
+ /** The department's role order (ascending); a person's map pin uses the first role in this order that has an icon. Undefined from older servers. */
+ public SortOrder?: number;
}
diff --git a/src/models/v4/unitStatus/unitStatusResultData.ts b/src/models/v4/unitStatus/unitStatusResultData.ts
index 3673ca0..cefdf2c 100644
--- a/src/models/v4/unitStatus/unitStatusResultData.ts
+++ b/src/models/v4/unitStatus/unitStatusResultData.ts
@@ -18,4 +18,8 @@ export class UnitStatusResultData {
public GroupName: string = '';
public GroupId: number | string = '';
public Eta: string = '';
+ /** Absolute URL of the department's uploaded icon for this unit's type (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the unit's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
}
diff --git a/src/models/v4/units/unitInfoResultData.ts b/src/models/v4/units/unitInfoResultData.ts
index b10739c..dcb1bc4 100644
--- a/src/models/v4/units/unitInfoResultData.ts
+++ b/src/models/v4/units/unitInfoResultData.ts
@@ -28,4 +28,8 @@ export class UnitInfoResultData {
public Longitude: string = '';
public Note: string = '';
public Roles: UnitRoleData[] = [];
+ /** Absolute URL of the department's uploaded icon for this unit's type (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the unit's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
}
diff --git a/src/models/v4/units/unitResultData.ts b/src/models/v4/units/unitResultData.ts
index 09345af..a7870c9 100644
--- a/src/models/v4/units/unitResultData.ts
+++ b/src/models/v4/units/unitResultData.ts
@@ -17,4 +17,8 @@ export class UnitResultData {
public Latitude: string = '';
public Longitude: string = '';
public Note: string = '';
+ /** Absolute URL of the department's uploaded icon for this unit's type (128x128 transparent PNG, anonymous GET). Null when none; undefined from older servers. */
+ public TypeIconUrl?: string | null = null;
+ /** Built-in map icon name the unit's type picked (lowercase, a MAP_ICONS key). Null when none; undefined from older servers. */
+ public TypeMapIcon?: string | null = null;
}
diff --git a/src/translations/ar.json b/src/translations/ar.json
index cfa365c..17ce7fc 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "منطقة داخلية",
"layer_toggles": "التحكم بالطبقات",
"layers_on": "{{active}} / {{total}} نشط",
+ "legend": {
+ "built_in_unit_statuses": "حالات الوحدات المدمجة",
+ "calls": "البلاغات",
+ "error": "تعذّر تحميل دليل الخريطة.",
+ "hydrant": "صنبور إطفاء",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "بلاغات بلا نوع",
+ "no_role_icon": "أعضاء بلا رمز دور",
+ "no_unit_type": "وحدات بلا نوع",
+ "other": "المحطات ونقاط الاهتمام",
+ "other_with_hydrants": "المحطات وصنابير الإطفاء ونقاط الاهتمام",
+ "personnel": "الأفراد",
+ "personnel_note": "يعرض العضو رمز أول دور يشغله له رمز. ويأخذ العضو «على وحدة» لون حالة وحدته.",
+ "priorities": "الأولويات",
+ "priority_colors": "ألوان الأولويات",
+ "station": "محطة",
+ "status_colors": "ألوان الحالات",
+ "statuses_line": "الحالات: {{name}}",
+ "style": {
+ "available": "متاح",
+ "delayed": "في استراحة",
+ "dispatched": "مُرسَل",
+ "emergency": "طارئة",
+ "high": "عالية",
+ "low": "منخفضة",
+ "medium": "متوسطة",
+ "onscene": "في الموقع",
+ "responding": "يستجيب",
+ "returning": "عائد",
+ "staging": "تجمّع واستعداد",
+ "transporting": "ينقل",
+ "unavailable": "غير متاح",
+ "unknown_priority": "بلا معنى",
+ "unknown_status": "بلا معنى أو بلا حالة"
+ },
+ "title": "دليل الخريطة",
+ "units": "الوحدات"
+ },
"loading": "جارٍ تحميل الخرائط...",
"map_layers": "طبقات الخريطة",
"no_layers": "لا توجد طبقات خريطة متاحة",
diff --git a/src/translations/de.json b/src/translations/de.json
index 6ca7f92..f10e7ee 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Innenbereichszone",
"layer_toggles": "Ebenenumschalter",
"layers_on": "{{active}} / {{total}} aktiv",
+ "legend": {
+ "built_in_unit_statuses": "Eingebaute Einheitenstatus",
+ "calls": "Einsätze",
+ "error": "Die Kartenlegende konnte nicht geladen werden.",
+ "hydrant": "Hydrant",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Einsätze ohne Typ",
+ "no_role_icon": "Mitglieder ohne Rollensymbol",
+ "no_unit_type": "Einheiten ohne Typ",
+ "other": "Wachen und Points of Interest",
+ "other_with_hydrants": "Wachen, Hydranten und Points of Interest",
+ "personnel": "Personal",
+ "personnel_note": "Ein Mitglied zeigt das Symbol der ersten seiner Rollen, die eines hat. Ein Mitglied „Auf Einheit“ nimmt die Farbe des Status seiner Einheit an.",
+ "priorities": "Prioritäten",
+ "priority_colors": "Prioritätsfarben",
+ "station": "Wache",
+ "status_colors": "Statusfarben",
+ "statuses_line": "Status: {{name}}",
+ "style": {
+ "available": "Verfügbar",
+ "delayed": "Pause",
+ "dispatched": "Alarmiert",
+ "emergency": "Notfall",
+ "high": "Hoch",
+ "low": "Niedrig",
+ "medium": "Mittel",
+ "onscene": "Vor Ort",
+ "responding": "Auf Anfahrt",
+ "returning": "Rückfahrt",
+ "staging": "Bereitstellung und Bereitschaft",
+ "transporting": "Transport",
+ "unavailable": "Nicht verfügbar",
+ "unknown_priority": "Keine Bedeutung",
+ "unknown_status": "Keine Bedeutung oder kein Status"
+ },
+ "title": "Kartenlegende",
+ "units": "Einheiten"
+ },
"loading": "Karten werden geladen...",
"map_layers": "Kartenebenen",
"no_layers": "Keine Kartenebenen verfügbar",
diff --git a/src/translations/el.json b/src/translations/el.json
index b104041..80b2624 100644
--- a/src/translations/el.json
+++ b/src/translations/el.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Ζώνη Εσωτερικού Χώρου",
"layer_toggles": "Διακόπτες Επιπέδων",
"layers_on": "{{active}} / {{total}} ενεργά",
+ "legend": {
+ "built_in_unit_statuses": "Ενσωματωμένες καταστάσεις μονάδων",
+ "calls": "Κλήσεις",
+ "error": "Δεν ήταν δυνατή η φόρτωση του υπομνήματος του χάρτη.",
+ "hydrant": "Κρουνός",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Κλήσεις χωρίς τύπο",
+ "no_role_icon": "Μέλη χωρίς εικονίδιο ρόλου",
+ "no_unit_type": "Μονάδες χωρίς τύπο",
+ "other": "Σταθμοί και σημεία ενδιαφέροντος",
+ "other_with_hydrants": "Σταθμοί, κρουνοί και σημεία ενδιαφέροντος",
+ "personnel": "Προσωπικό",
+ "personnel_note": "Ένα μέλος δείχνει το εικονίδιο του πρώτου ρόλου του που έχει εικονίδιο. Ένα μέλος «Σε μονάδα» παίρνει το χρώμα της κατάστασης της μονάδας του.",
+ "priorities": "Προτεραιότητες",
+ "priority_colors": "Χρώματα προτεραιοτήτων",
+ "station": "Σταθμός",
+ "status_colors": "Χρώματα καταστάσεων",
+ "statuses_line": "Καταστάσεις: {{name}}",
+ "style": {
+ "available": "Διαθέσιμος",
+ "delayed": "Σε διάλειμμα",
+ "dispatched": "Σε αποστολή",
+ "emergency": "Έκτακτη ανάγκη",
+ "high": "Υψηλή",
+ "low": "Χαμηλή",
+ "medium": "Μεσαία",
+ "onscene": "Επί τόπου",
+ "responding": "Σε ανταπόκριση",
+ "returning": "Σε επιστροφή",
+ "staging": "Συγκέντρωση και αναμονή",
+ "transporting": "Σε μεταφορά",
+ "unavailable": "Μη διαθέσιμος",
+ "unknown_priority": "Χωρίς σημασία",
+ "unknown_status": "Χωρίς σημασία ή χωρίς κατάσταση"
+ },
+ "title": "Υπόμνημα χάρτη",
+ "units": "Μονάδες"
+ },
"loading": "Φόρτωση χαρτών...",
"map_layers": "Επίπεδα Χάρτη",
"no_layers": "Δεν υπάρχουν διαθέσιμα επίπεδα χάρτη",
diff --git a/src/translations/en.json b/src/translations/en.json
index a7fbea3..80f27ce 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Indoor Zone",
"layer_toggles": "Layer Toggles",
"layers_on": "{{active}} / {{total}} active",
+ "legend": {
+ "built_in_unit_statuses": "Built-in unit statuses",
+ "calls": "Calls",
+ "error": "Could not load the map legend.",
+ "hydrant": "Hydrant",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Calls with no type",
+ "no_role_icon": "Members with no role icon",
+ "no_unit_type": "Units with no type",
+ "other": "Stations and points of interest",
+ "other_with_hydrants": "Stations, hydrants and points of interest",
+ "personnel": "Personnel",
+ "personnel_note": "A member shows the icon of the first role they hold that has one. A member On Unit takes the color of their unit's status.",
+ "priorities": "Priorities",
+ "priority_colors": "Priority colors",
+ "station": "Station",
+ "status_colors": "Status colors",
+ "statuses_line": "Statuses: {{name}}",
+ "style": {
+ "available": "Available",
+ "delayed": "On break",
+ "dispatched": "Dispatched",
+ "emergency": "Emergency",
+ "high": "High",
+ "low": "Low",
+ "medium": "Medium",
+ "onscene": "On scene",
+ "responding": "Responding",
+ "returning": "Returning",
+ "staging": "Staging and standby",
+ "transporting": "Transporting",
+ "unavailable": "Unavailable",
+ "unknown_priority": "No meaning",
+ "unknown_status": "No meaning or no status"
+ },
+ "title": "Map legend",
+ "units": "Units"
+ },
"loading": "Loading maps...",
"map_layers": "Map Layers",
"no_layers": "No map layers available",
diff --git a/src/translations/es.json b/src/translations/es.json
index a6369ea..1d92cc2 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Zona Interior",
"layer_toggles": "Controles de Capas",
"layers_on": "{{active}} / {{total}} activos",
+ "legend": {
+ "built_in_unit_statuses": "Estados de unidad integrados",
+ "calls": "Llamadas",
+ "error": "No se pudo cargar la leyenda del mapa.",
+ "hydrant": "Hidrante",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Llamadas sin tipo",
+ "no_role_icon": "Miembros sin icono de rol",
+ "no_unit_type": "Unidades sin tipo",
+ "other": "Estaciones y puntos de interés",
+ "other_with_hydrants": "Estaciones, hidrantes y puntos de interés",
+ "personnel": "Personal",
+ "personnel_note": "Un miembro muestra el icono del primero de sus roles que tenga uno. Un miembro «En unidad» toma el color del estado de su unidad.",
+ "priorities": "Prioridades",
+ "priority_colors": "Colores de prioridad",
+ "station": "Estación",
+ "status_colors": "Colores de estado",
+ "statuses_line": "Estados: {{name}}",
+ "style": {
+ "available": "Disponible",
+ "delayed": "En descanso",
+ "dispatched": "Despachado",
+ "emergency": "Emergencia",
+ "high": "Alta",
+ "low": "Baja",
+ "medium": "Media",
+ "onscene": "En el lugar",
+ "responding": "En respuesta",
+ "returning": "Regresando",
+ "staging": "Concentración y espera",
+ "transporting": "Trasladando",
+ "unavailable": "No disponible",
+ "unknown_priority": "Sin significado",
+ "unknown_status": "Sin significado o sin estado"
+ },
+ "title": "Leyenda del mapa",
+ "units": "Unidades"
+ },
"loading": "Cargando mapas...",
"map_layers": "Capas del mapa",
"no_layers": "No hay capas de mapa disponibles",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index 7a8f573..7288a9a 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Zone intérieure",
"layer_toggles": "Commutateurs de couches",
"layers_on": "{{active}} / {{total}} actifs",
+ "legend": {
+ "built_in_unit_statuses": "Statuts d'unité intégrés",
+ "calls": "Appels",
+ "error": "Impossible de charger la légende de la carte.",
+ "hydrant": "Borne d'incendie",
+ "names_line": "{{label}} : {{names}}",
+ "no_call_type": "Appels sans type",
+ "no_role_icon": "Membres sans icône de rôle",
+ "no_unit_type": "Unités sans type",
+ "other": "Casernes et points d'intérêt",
+ "other_with_hydrants": "Casernes, bornes d'incendie et points d'intérêt",
+ "personnel": "Personnel",
+ "personnel_note": "Un membre affiche l'icône du premier de ses rôles qui en a une. Un membre « Sur une unité » prend la couleur du statut de son unité.",
+ "priorities": "Priorités",
+ "priority_colors": "Couleurs des priorités",
+ "station": "Caserne",
+ "status_colors": "Couleurs des statuts",
+ "statuses_line": "Statuts : {{name}}",
+ "style": {
+ "available": "Disponible",
+ "delayed": "En pause",
+ "dispatched": "Engagé",
+ "emergency": "Urgence",
+ "high": "Haute",
+ "low": "Basse",
+ "medium": "Moyenne",
+ "onscene": "Sur les lieux",
+ "responding": "En intervention",
+ "returning": "En retour",
+ "staging": "Regroupement et attente",
+ "transporting": "En transport",
+ "unavailable": "Indisponible",
+ "unknown_priority": "Sans signification",
+ "unknown_status": "Sans signification ou sans statut"
+ },
+ "title": "Légende de la carte",
+ "units": "Unités"
+ },
"loading": "Chargement des cartes...",
"map_layers": "Couches de carte",
"no_layers": "Aucune couche de carte disponible",
diff --git a/src/translations/it.json b/src/translations/it.json
index b2bf0f0..5487b28 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Zona interna",
"layer_toggles": "Interruttori livelli",
"layers_on": "{{active}} / {{total}} attivi",
+ "legend": {
+ "built_in_unit_statuses": "Stati unità integrati",
+ "calls": "Chiamate",
+ "error": "Impossibile caricare la legenda della mappa.",
+ "hydrant": "Idrante",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Chiamate senza tipo",
+ "no_role_icon": "Membri senza icona di ruolo",
+ "no_unit_type": "Unità senza tipo",
+ "other": "Stazioni e punti di interesse",
+ "other_with_hydrants": "Stazioni, idranti e punti di interesse",
+ "personnel": "Personale",
+ "personnel_note": "Un membro mostra l'icona del primo dei suoi ruoli che ne ha una. Un membro «Su unità» assume il colore dello stato della sua unità.",
+ "priorities": "Priorità",
+ "priority_colors": "Colori delle priorità",
+ "station": "Stazione",
+ "status_colors": "Colori degli stati",
+ "statuses_line": "Stati: {{name}}",
+ "style": {
+ "available": "Disponibile",
+ "delayed": "In pausa",
+ "dispatched": "Inviato",
+ "emergency": "Emergenza",
+ "high": "Alta",
+ "low": "Bassa",
+ "medium": "Media",
+ "onscene": "Sul posto",
+ "responding": "In risposta",
+ "returning": "In rientro",
+ "staging": "Ammassamento e attesa",
+ "transporting": "In trasporto",
+ "unavailable": "Non disponibile",
+ "unknown_priority": "Nessun significato",
+ "unknown_status": "Nessun significato o nessuno stato"
+ },
+ "title": "Legenda della mappa",
+ "units": "Unità"
+ },
"loading": "Caricamento mappe...",
"map_layers": "Livelli mappa",
"no_layers": "Nessun livello mappa disponibile",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 9b7b511..75c6ff1 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Strefa wewnętrzna",
"layer_toggles": "Przełączniki warstw",
"layers_on": "{{active}} / {{total}} aktywne",
+ "legend": {
+ "built_in_unit_statuses": "Wbudowane statusy jednostek",
+ "calls": "Zgłoszenia",
+ "error": "Nie udało się wczytać legendy mapy.",
+ "hydrant": "Hydrant",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Zgłoszenia bez typu",
+ "no_role_icon": "Członkowie bez ikony roli",
+ "no_unit_type": "Jednostki bez typu",
+ "other": "Stacje i punkty zainteresowania",
+ "other_with_hydrants": "Stacje, hydranty i punkty zainteresowania",
+ "personnel": "Personel",
+ "personnel_note": "Członek pokazuje ikonę pierwszej posiadanej roli, która ją ma. Członek „W jednostce” przyjmuje kolor statusu swojej jednostki.",
+ "priorities": "Priorytety",
+ "priority_colors": "Kolory priorytetów",
+ "station": "Stacja",
+ "status_colors": "Kolory statusów",
+ "statuses_line": "Statusy: {{name}}",
+ "style": {
+ "available": "Dostępny",
+ "delayed": "Przerwa",
+ "dispatched": "Zadysponowany",
+ "emergency": "Nagły",
+ "high": "Wysoki",
+ "low": "Niski",
+ "medium": "Średni",
+ "onscene": "Na miejscu",
+ "responding": "W drodze",
+ "returning": "Powrót",
+ "staging": "Strefa wyczekiwania i gotowość",
+ "transporting": "W transporcie",
+ "unavailable": "Niedostępny",
+ "unknown_priority": "Brak znaczenia",
+ "unknown_status": "Brak znaczenia lub statusu"
+ },
+ "title": "Legenda mapy",
+ "units": "Jednostki"
+ },
"loading": "Ładowanie map...",
"map_layers": "Warstwy mapy",
"no_layers": "Brak dostępnych warstw mapy",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index bd5d18d..c2181aa 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Inomhuszon",
"layer_toggles": "Lagerväxling",
"layers_on": "{{active}} / {{total}} aktiva",
+ "legend": {
+ "built_in_unit_statuses": "Inbyggda enhetsstatusar",
+ "calls": "Larm",
+ "error": "Det gick inte att läsa in kartförklaringen.",
+ "hydrant": "Brandpost",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Larm utan typ",
+ "no_role_icon": "Medlemmar utan rollikon",
+ "no_unit_type": "Enheter utan typ",
+ "other": "Stationer och intressepunkter",
+ "other_with_hydrants": "Stationer, brandposter och intressepunkter",
+ "personnel": "Personal",
+ "personnel_note": "En medlem visar ikonen för den första roll de har som har en. En medlem ”På enhet” får färgen för sin enhets status.",
+ "priorities": "Prioriteter",
+ "priority_colors": "Prioritetsfärger",
+ "station": "Station",
+ "status_colors": "Statusfärger",
+ "statuses_line": "Statusar: {{name}}",
+ "style": {
+ "available": "Tillgänglig",
+ "delayed": "Rast",
+ "dispatched": "Larmad",
+ "emergency": "Akut",
+ "high": "Hög",
+ "low": "Låg",
+ "medium": "Medel",
+ "onscene": "På plats",
+ "responding": "På väg",
+ "returning": "Återvänder",
+ "staging": "Uppställning och beredskap",
+ "transporting": "Transporterar",
+ "unavailable": "Inte tillgänglig",
+ "unknown_priority": "Ingen betydelse",
+ "unknown_status": "Ingen betydelse eller ingen status"
+ },
+ "title": "Kartförklaring",
+ "units": "Enheter"
+ },
"loading": "Laddar kartor...",
"map_layers": "Kartlager",
"no_layers": "Inga kartlager tillgängliga",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index fec13eb..020bfa2 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -1389,6 +1389,44 @@
"indoor_zone": "Внутрішня зона",
"layer_toggles": "Перемикачі шарів",
"layers_on": "{{active}} / {{total}} активних",
+ "legend": {
+ "built_in_unit_statuses": "Вбудовані статуси підрозділів",
+ "calls": "Виклики",
+ "error": "Не вдалося завантажити легенду карти.",
+ "hydrant": "Гідрант",
+ "names_line": "{{label}}: {{names}}",
+ "no_call_type": "Виклики без типу",
+ "no_role_icon": "Учасники без значка ролі",
+ "no_unit_type": "Підрозділи без типу",
+ "other": "Станції та точки інтересу",
+ "other_with_hydrants": "Станції, гідранти та точки інтересу",
+ "personnel": "Персонал",
+ "personnel_note": "Учасник показує значок першої своєї ролі, яка його має. Учасник «У підрозділі» набуває кольору статусу свого підрозділу.",
+ "priorities": "Пріоритети",
+ "priority_colors": "Кольори пріоритетів",
+ "station": "Станція",
+ "status_colors": "Кольори статусів",
+ "statuses_line": "Статуси: {{name}}",
+ "style": {
+ "available": "Доступний",
+ "delayed": "Перерва",
+ "dispatched": "Направлений",
+ "emergency": "Екстрений",
+ "high": "Високий",
+ "low": "Низький",
+ "medium": "Середній",
+ "onscene": "На місці",
+ "responding": "Реагує",
+ "returning": "Повертається",
+ "staging": "Збір і очікування",
+ "transporting": "Транспортує",
+ "unavailable": "Недоступний",
+ "unknown_priority": "Без значення",
+ "unknown_status": "Без значення або без статусу"
+ },
+ "title": "Легенда карти",
+ "units": "Підрозділи"
+ },
"loading": "Завантаження карт...",
"map_layers": "Шари карти",
"no_layers": "Немає доступних шарів карти",