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) => ( + + ) : 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": "Немає доступних шарів карти",