diff --git a/Core/Resgrid.AdminAssist/Catalog/calls.yaml b/Core/Resgrid.AdminAssist/Catalog/calls.yaml
index 929497145..06da6ecc3 100644
--- a/Core/Resgrid.AdminAssist/Catalog/calls.yaml
+++ b/Core/Resgrid.AdminAssist/Catalog/calls.yaml
@@ -3732,23 +3732,23 @@
"Id": "call-types-priorities",
"Locale": "en",
"TitleKey": "Feature.call-types-priorities",
- "Body": "Call types and priorities The call types and priorities used to classify calls. Use consistent reporting and run-card matching terms. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map.",
+ "Body": "Call types and priorities The call types and priorities used to classify calls. Use consistent reporting and run-card matching terms. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map. A priority's meaning also colors its calls' map pins, and the Map Icon Legend lists each priority under its color.",
"SourcePath": "docs/admin-assist/settings-reference.md",
"Anchor": "call-types-priorities",
"PackVersion": "2026.09.25.2",
"Owner": "Resgrid.Core",
- "ReviewedOn": "2026-10-21T00:00:00Z"
+ "ReviewedOn": "2026-10-25T00:00:00Z"
},
{
"Id": "custom-statuses",
"Locale": "en",
"TitleKey": "Feature.custom-statuses",
- "Body": "Personnel and unit statuses The statuses members and units report, such as Responding or On Scene. Use terminology familiar to the department while preserving automation meaning. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station.",
+ "Body": "Personnel and unit statuses The statuses members and units report, such as Responding or On Scene. Use terminology familiar to the department while preserving automation meaning. A department adds Structure Fire and Medical call types, sets Responding and On Scene statuses and connects its county CAD email feed. Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station. The Map Icon Legend lists each status under the map color its meaning gives it; one listed under gray has no meaning yet.",
"SourcePath": "docs/admin-assist/settings-reference.md",
"Anchor": "custom-statuses",
"PackVersion": "2026.09.25.2",
"Owner": "Resgrid.Core",
- "ReviewedOn": "2026-10-13T00:00:00Z"
+ "ReviewedOn": "2026-10-25T00:00:00Z"
},
{
"Id": "dispatch-settings",
diff --git a/Core/Resgrid.AdminAssist/Catalog/mapping.yaml b/Core/Resgrid.AdminAssist/Catalog/mapping.yaml
index 2e3325343..2b5716ed4 100644
--- a/Core/Resgrid.AdminAssist/Catalog/mapping.yaml
+++ b/Core/Resgrid.AdminAssist/Catalog/mapping.yaml
@@ -1193,6 +1193,25 @@
"Prominence": "Detail",
"DocsPath": "/web-app/mapping/#custom-map-icons"
},
+ {
+ "Id": "map-icon-legend",
+ "AreaId": "mapping",
+ "LabelKey": "Feature.map-icon-legend",
+ "PurposeKey": "FeaturePurpose.map-icon-legend",
+ "ValueKey": "AreaValue.mapping",
+ "ExampleKey": "AreaExample.mapping",
+ "AdoptionKey": "FeatureSetup.map-icon-legend",
+ "ReleaseStatus": "available",
+ "Location": {
+ "Controller": "Mapping",
+ "Action": "Legend"
+ },
+ "Requirements": [],
+ "SettingIds": [],
+ "RuleIds": [],
+ "Prominence": "Detail",
+ "DocsPath": "/web-app/mapping/#map-icon-legend"
+ },
{
"Id": "map-visualizations",
"AreaId": "mapping",
@@ -1921,12 +1940,23 @@
"Id": "custom-map-icons",
"Locale": "en",
"TitleKey": "Feature.custom-map-icons",
- "Body": "Custom map icons Your own icons for unit types, call types, points of interest and personnel roles, shown in lists, the apps and on the map. A current map shows where responders and apparatus are and puts important locations in front of dispatchers. Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page. Icons can be PNG, JPEG, GIF (first frame) or WebP files up to 1 MB and between 16 and 4096 px on each edge; SVG is not accepted. Every icon is stored as a 128 × 128 px PNG and drawn at 32 × 32 px on the map and 20 × 20 px in lists, so a bold shape works better than fine detail or text. A type without a custom icon keeps its built-in map icon. A person's map pin uses the icon of the first role they hold, in the role order, that has one; people without one keep the default person marker.",
+ "Body": "Custom map icons Your own icons for unit types, call types, points of interest and personnel roles, shown in lists, the apps and on the map. A current map shows where responders and apparatus are and puts important locations in front of dispatchers. Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page. Use Status map icons or Priority map icons on the same page to add an icon for each status or priority. Icons can be PNG, JPEG, GIF (first frame) or WebP files up to 1 MB and between 16 and 4096 px on each edge; SVG is not accepted. Every icon is stored as a 128 × 128 px PNG and drawn at 32 × 32 px on the map and 20 × 20 px in lists, so a bold shape works better than fine detail or text. A type without a custom icon keeps its built-in map icon. A person's map pin uses the icon of the first role they hold, in the role order, that has one; people without one keep the default person marker. A call type's icon for a priority, and a unit type's or role's icon for a status, replaces its own icon on the map while that priority or status holds; without one, the type's or role's own icon carries a small badge in the color of the priority or status. Types and roles with no uploaded icon show the built-in pin in that color. A priority or status with no meaning set takes the color its name suggests, and shows a gray pin when the name suggests none. Mapping > Map Icon Legend shows every combination.",
"SourcePath": "docs/admin-assist/settings-reference.md",
"Anchor": "custom-map-icons",
"PackVersion": "2026.09.25.2",
"Owner": "Resgrid.Core",
- "ReviewedOn": "2026-10-21T00:00:00Z"
+ "ReviewedOn": "2026-10-24T00:00:00Z"
+ },
+ {
+ "Id": "map-icon-legend",
+ "Locale": "en",
+ "TitleKey": "Feature.map-icon-legend",
+ "Body": "Map icon legend Every icon the map shows for calls, units and personnel in each priority and status, with the status and priority colors. A current map shows where responders and apparatus are and puts important locations in front of dispatchers. Open Mapping, then Map Icon Legend. Department admins can choose Edit icons on a call type, unit type or role to upload an icon for a priority or status. In the Responder, Unit and IC apps, tap the legend button on the map to see the same legend. A call's icon follows its type and priority, a unit's its type and status, and a member's their role and status; staffing levels never change an icon. A member On Unit takes the color of their unit's status. Built-in pins take the color of the meaning of the status or priority: Available green, Dispatched amber, Responding red, On scene blue, Transporting purple, Returning teal, Staging and standby brown, On break slate, Unavailable charcoal; priorities Low green, Medium yellow, High orange, Emergency red. A status or priority with no meaning set takes the color its name suggests (such as Emergency or Out of Service) and shows a gray pin when the name suggests none, as does a unit or member that has set no status, so set meanings on the Custom Statuses and Call Priorities pages to be sure. The maps on New Call and Update Call, and the call maps in the Dispatch app, draw only available units and personnel; the dispatch lists still show everyone with their status. Each status and priority color lists the department's own statuses and priorities that show in it, and every name, icon and upload in the legend comes from the department's own call types, priorities, unit types, status sets, statuses and roles, so it changes as soon as one of them or an icon does. The legend lists On Unit under no color, since it follows the unit. In the apps the legend button is at the top right of the Responder map, above the recenter button on the Unit map, and above the layers button on the IC main map and on an incident's command map; Responder leaves personnel out of the legend for members who can't see personal information, as its map does.",
+ "SourcePath": "docs/admin-assist/settings-reference.md",
+ "Anchor": "map-icon-legend",
+ "PackVersion": "2026.09.25.2",
+ "Owner": "Resgrid.Core",
+ "ReviewedOn": "2026-10-25T00:00:00Z"
},
{
"Id": "map-visualizations",
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx
index 1152f1911..fb978ab13 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.ar.resx
@@ -1624,7 +1624,7 @@
أضف اجتماعات وليالي تدريب متكررة.
- أضف أنواع البلاغات التي يستخدمها المرسلون لديك وحدد أولوياتها. امنح كل نوع بلاغ فئة وكل أولوية معنى حتى تتمكن التقارير والتطبيقات من تجميعها وترتيبها. يمكن أيضًا أن يكون لنوع البلاغ أيقونة مخصصة تظهر بجانب بلاغاته في القوائم والتطبيقات وعلى الخريطة.
+ أضف أنواع البلاغات التي يستخدمها المرسلون لديك وحدد أولوياتها. امنح كل نوع بلاغ فئة وكل أولوية معنى حتى تتمكن التقارير والتطبيقات من تجميعها وترتيبها. يمكن أيضًا أن يكون لنوع البلاغ أيقونة مخصصة تظهر بجانب بلاغاته في القوائم والتطبيقات وعلى الخريطة. يلوّن معنى الأولوية أيضًا دبابيس بلاغاتها على الخريطة، ويعرض دليل رموز الخريطة كل أولوية تحت لونها.
سجّل الشهادات الحالية للأعضاء، ثم راجع ما سينتهي قريبًا.
@@ -1654,7 +1654,7 @@
أضف عقود الخدمة الخاصة بك وأسعارها.
- راجع الحالات المدمجة وأضف حالاتك حيث تختلف مصطلحاتك. امنح كل خيار معنى (نوعًا أساسيًا): يقرأ الإرسال والتوفر والإشعارات والتطبيقات المعنى، لا نص الزر. تخبر حالة «في المركز» توصيات الإرسال بأن الوحدة في مركزها.
+ راجع الحالات المدمجة وأضف حالاتك حيث تختلف مصطلحاتك. امنح كل خيار معنى (نوعًا أساسيًا): يقرأ الإرسال والتوفر والإشعارات والتطبيقات المعنى، لا نص الزر. تخبر حالة «في المركز» توصيات الإرسال بأن الوحدة في مركزها. يعرض دليل رموز الخريطة كل حالة تحت اللون الذي يمنحه لها معناها، والحالة المدرجة تحت الرمادي لا معنى لها بعد.
راجع قائمة تحقق التسجيل وخطّط لاسترداد المفاتيح قبل التسجيل.
@@ -7075,7 +7075,16 @@
أيقونات خاصة بك لأنواع الوحدات وأنواع البلاغات ونقاط الاهتمام وأدوار الأفراد، تظهر في القوائم والتطبيقات وعلى الخريطة.
- ارفع صورة PNG مربعة بخلفية شفافة بحجم 128 × 128 بكسل أو أكبر في صفحة نوع الوحدة أو نوع البلاغ أو نوع نقطة الاهتمام أو الدور؛ وتعرض الصفحة معاينة على خريطة فاتحة وأخرى داكنة. لاختيار الأيقونة التي تظهر لشخص لديه عدة أدوار، رتّب الأدوار في صفحة الأدوار.
+ ارفع صورة PNG مربعة بخلفية شفافة بحجم 128 × 128 بكسل أو أكبر في صفحة نوع الوحدة أو نوع البلاغ أو نوع نقطة الاهتمام أو الدور؛ وتعرض الصفحة معاينة على خريطة فاتحة وأخرى داكنة. لاختيار الأيقونة التي تظهر لشخص لديه عدة أدوار، رتّب الأدوار في صفحة الأدوار. استخدم رموز الخريطة حسب الحالة أو حسب الأولوية في الصفحة نفسها لإضافة رمز لكل حالة أو أولوية.
+
+
+ دليل رموز الخريطة
+
+
+ كل الرموز التي تعرضها الخريطة للبلاغات والوحدات والأفراد في كل أولوية وحالة، مع ألوان الحالات والأولويات.
+
+
+ افتح الخريطة ثم دليل رموز الخريطة. ويمكن لمسؤولي القسم اختيار تعديل الرموز لنوع بلاغ أو نوع وحدة أو دور لرفع رمز لأولوية أو حالة. في تطبيقات Responder وUnit وIC، اضغط زر الدليل على الخريطة لعرض الدليل نفسه.
التصورات المرئية للخريطة
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx
index ef4d61536..299ba7b1c 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.de.resx
@@ -1624,7 +1624,7 @@
Fügen Sie wiederkehrende Besprechungen und Ausbildungsabende hinzu.
- Fügen Sie die Einsatztypen hinzu, die Ihre Disponenten verwenden, und legen Sie deren Prioritäten fest. Geben Sie jedem Einsatztyp eine Klasse und jeder Priorität eine Bedeutung, damit Berichte und die Apps sie gruppieren und sortieren können. Ein Einsatztyp kann auch ein eigenes Symbol erhalten, das neben seinen Einsätzen in Listen, den Apps und auf der Karte angezeigt wird.
+ Fügen Sie die Einsatztypen hinzu, die Ihre Disponenten verwenden, und legen Sie deren Prioritäten fest. Geben Sie jedem Einsatztyp eine Klasse und jeder Priorität eine Bedeutung, damit Berichte und die Apps sie gruppieren und sortieren können. Ein Einsatztyp kann auch ein eigenes Symbol erhalten, das neben seinen Einsätzen in Listen, den Apps und auf der Karte angezeigt wird. Die Bedeutung einer Priorität färbt auch die Kartenmarkierungen ihrer Einsätze, und die Legende der Kartensymbole führt jede Priorität unter ihrer Farbe auf.
Erfassen Sie die aktuellen Zertifizierungen der Mitglieder und prüfen Sie anschließend, was bald abläuft.
@@ -1654,7 +1654,7 @@
Fügen Sie Ihre Dienstleistungsverträge und deren Tarife hinzu.
- Prüfen Sie die integrierten Status und fügen Sie eigene hinzu, wo Ihre Begriffe abweichen. Geben Sie jeder Option eine Bedeutung (Basistyp): Disposition, Verfügbarkeit, Benachrichtigungen und die Apps lesen die Bedeutung, nicht den Schaltflächentext. „In der Wache“ teilt den Dispositionsempfehlungen mit, dass eine Einheit auf ihrer Wache ist.
+ Prüfen Sie die integrierten Status und fügen Sie eigene hinzu, wo Ihre Begriffe abweichen. Geben Sie jeder Option eine Bedeutung (Basistyp): Disposition, Verfügbarkeit, Benachrichtigungen und die Apps lesen die Bedeutung, nicht den Schaltflächentext. „In der Wache“ teilt den Dispositionsempfehlungen mit, dass eine Einheit auf ihrer Wache ist. Die Legende der Kartensymbole führt jeden Status unter der Kartenfarbe auf, die seine Bedeutung ergibt; ein Status unter Grau hat noch keine Bedeutung.
Prüfen Sie die Checkliste zur Registrierung und planen Sie die Schlüsselwiederherstellung, bevor Sie sich registrieren.
@@ -7075,7 +7075,16 @@
Eigene Symbole für Einheitentypen, Einsatztypen, Points of Interest und Personalrollen, angezeigt in Listen, den Apps und auf der Karte.
- Laden Sie auf der Seite des Einheitentyps, Einsatztyps, POI-Typs oder der Rolle ein quadratisches PNG mit transparentem Hintergrund ab 128 × 128 px hoch; die Seite zeigt eine Vorschau auf einer hellen und einer dunklen Karte. Welches Symbol eine Person mit mehreren Rollen erhält, legen Sie über die Reihenfolge auf der Rollenseite fest.
+ Laden Sie auf der Seite des Einheitentyps, Einsatztyps, POI-Typs oder der Rolle ein quadratisches PNG mit transparentem Hintergrund ab 128 × 128 px hoch; die Seite zeigt eine Vorschau auf einer hellen und einer dunklen Karte. Welches Symbol eine Person mit mehreren Rollen erhält, legen Sie über die Reihenfolge auf der Rollenseite fest. Über „Kartensymbole nach Status“ bzw. „Kartensymbole nach Priorität“ auf derselben Seite fügen Sie ein Symbol für jeden Status oder jede Priorität hinzu.
+
+
+ Legende der Kartensymbole
+
+
+ Alle Symbole, die die Karte für Einsätze, Einheiten und Personal in jeder Priorität und jedem Status zeigt, mit den Status- und Prioritätsfarben.
+
+
+ Öffnen Sie Karte und dann Legende der Kartensymbole. Abteilungsadministratoren können bei einem Einsatztyp, Einheitentyp oder einer Rolle „Symbole bearbeiten“ wählen, um ein Symbol für eine Priorität oder einen Status hochzuladen. In den Apps Responder, Unit und IC zeigt die Legenden-Schaltfläche auf der Karte dieselbe Legende.
Kartenvisualisierungen
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx
index 380449461..3d2a3ae05 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.el.resx
@@ -1624,7 +1624,7 @@
Προσθέστε επαναλαμβανόμενες συναντήσεις και βραδιές εκπαίδευσης.
- Προσθέστε τους τύπους κλήσεων που χρησιμοποιούν οι χειριστές σας και ορίστε τις προτεραιότητές τους. Δώστε σε κάθε τύπο κλήσης μια κατηγορία και σε κάθε προτεραιότητα μια σημασία, ώστε οι αναφορές και οι εφαρμογές να τις ομαδοποιούν και να τις ταξινομούν. Ένας τύπος κλήσης μπορεί επίσης να έχει προσαρμοσμένο εικονίδιο, που εμφανίζεται δίπλα στις κλήσεις του σε λίστες, στις εφαρμογές και στον χάρτη.
+ Προσθέστε τους τύπους κλήσεων που χρησιμοποιούν οι χειριστές σας και ορίστε τις προτεραιότητές τους. Δώστε σε κάθε τύπο κλήσης μια κατηγορία και σε κάθε προτεραιότητα μια σημασία, ώστε οι αναφορές και οι εφαρμογές να τις ομαδοποιούν και να τις ταξινομούν. Ένας τύπος κλήσης μπορεί επίσης να έχει προσαρμοσμένο εικονίδιο, που εμφανίζεται δίπλα στις κλήσεις του σε λίστες, στις εφαρμογές και στον χάρτη. Η σημασία μιας προτεραιότητας χρωματίζει επίσης τις καρφίτσες των κλήσεών της στον χάρτη, και το υπόμνημα εικονιδίων χάρτη δείχνει κάθε προτεραιότητα κάτω από το χρώμα της.
Καταγράψτε τις τρέχουσες πιστοποιήσεις των μελών και, στη συνέχεια, εξετάστε τι λήγει σύντομα.
@@ -1654,7 +1654,7 @@
Προσθέστε τις συμβάσεις υπηρεσιών σας και τις χρεώσεις τους.
- Ελέγξτε τις ενσωματωμένες καταστάσεις και προσθέστε δικές σας όπου οι όροι σας διαφέρουν. Δώστε σε κάθε επιλογή μια σημασία (βασικό τύπο): η αποστολή, η διαθεσιμότητα, οι ειδοποιήσεις και οι εφαρμογές διαβάζουν τη σημασία, όχι το κείμενο του κουμπιού. Το «Στον σταθμό» δείχνει στις συστάσεις αποστολής ότι μια μονάδα βρίσκεται στον σταθμό της.
+ Ελέγξτε τις ενσωματωμένες καταστάσεις και προσθέστε δικές σας όπου οι όροι σας διαφέρουν. Δώστε σε κάθε επιλογή μια σημασία (βασικό τύπο): η αποστολή, η διαθεσιμότητα, οι ειδοποιήσεις και οι εφαρμογές διαβάζουν τη σημασία, όχι το κείμενο του κουμπιού. Το «Στον σταθμό» δείχνει στις συστάσεις αποστολής ότι μια μονάδα βρίσκεται στον σταθμό της. Το υπόμνημα εικονιδίων χάρτη δείχνει κάθε κατάσταση κάτω από το χρώμα που της δίνει η σημασία της· μια κατάσταση κάτω από το γκρι δεν έχει ακόμη σημασία.
Εξετάστε τη λίστα ελέγχου εγγραφής και σχεδιάστε την ανάκτηση κλειδιού πριν εγγραφείτε.
@@ -7075,7 +7075,16 @@
Δικά σας εικονίδια για τύπους μονάδων, τύπους κλήσεων, σημεία ενδιαφέροντος και ρόλους προσωπικού, που εμφανίζονται σε λίστες, στις εφαρμογές και στον χάρτη.
- Ανεβάστε ένα τετράγωνο PNG με διαφανές φόντο, 128 × 128 px ή μεγαλύτερο, στη σελίδα του τύπου μονάδας, του τύπου κλήσης, του τύπου POI ή του ρόλου· η σελίδα δείχνει προεπισκόπηση σε φωτεινό και σκούρο χάρτη. Για να επιλέξετε ποιο εικονίδιο εμφανίζει ένα άτομο με πολλούς ρόλους, ταξινομήστε τους ρόλους στη σελίδα Ρόλοι.
+ Ανεβάστε ένα τετράγωνο PNG με διαφανές φόντο, 128 × 128 px ή μεγαλύτερο, στη σελίδα του τύπου μονάδας, του τύπου κλήσης, του τύπου POI ή του ρόλου· η σελίδα δείχνει προεπισκόπηση σε φωτεινό και σκούρο χάρτη. Για να επιλέξετε ποιο εικονίδιο εμφανίζει ένα άτομο με πολλούς ρόλους, ταξινομήστε τους ρόλους στη σελίδα Ρόλοι. Χρησιμοποιήστε τα Εικονίδια χάρτη ανά κατάσταση ή ανά προτεραιότητα στην ίδια σελίδα για να προσθέσετε ένα εικονίδιο για κάθε κατάσταση ή προτεραιότητα.
+
+
+ Υπόμνημα εικονιδίων χάρτη
+
+
+ Όλα τα εικονίδια που δείχνει ο χάρτης για κλήσεις, μονάδες και προσωπικό σε κάθε προτεραιότητα και κατάσταση, με τα χρώματα καταστάσεων και προτεραιοτήτων.
+
+
+ Ανοίξτε τον Χάρτη και μετά το Υπόμνημα εικονιδίων χάρτη. Οι διαχειριστές του τμήματος μπορούν να επιλέξουν Επεξεργασία εικονιδίων σε έναν τύπο κλήσης, τύπο μονάδας ή ρόλο για να ανεβάσουν ένα εικονίδιο για μια προτεραιότητα ή κατάσταση. Στις εφαρμογές Responder, Unit και IC, πατήστε το κουμπί υπομνήματος στον χάρτη για να δείτε το ίδιο υπόμνημα.
Απεικονίσεις χάρτη
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx
index ff7bbc6c2..ad16be279 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.en.resx
@@ -1624,7 +1624,7 @@
Add recurring meetings and training nights.
- Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map.
+ Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map. A priority's meaning also colors its calls' map pins, and the Map Icon Legend lists each priority under its color.
Record members' current certifications, then review what expires soon.
@@ -1654,7 +1654,7 @@
Add your service contracts and their rates.
- Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station.
+ Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station. The Map Icon Legend lists each status under the map color its meaning gives it; one listed under gray has no meaning yet.
Review the enrollment checklist and plan key recovery before enrolling.
@@ -7075,7 +7075,16 @@
Your own icons for unit types, call types, points of interest and personnel roles, shown in lists, the apps and on the map.
- Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page.
+ Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page. Use Status map icons or Priority map icons on the same page to add an icon for each status or priority.
+
+
+ Map icon legend
+
+
+ Every icon the map shows for calls, units and personnel in each priority and status, with the status and priority colors.
+
+
+ Open Mapping, then Map Icon Legend. Department admins can choose Edit icons on a call type, unit type or role to upload an icon for a priority or status. In the Responder, Unit and IC apps, tap the legend button on the map to see the same legend.
Map visualizations
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx
index 46147e7fa..e4f7f6b09 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.es.resx
@@ -1624,7 +1624,7 @@
Agregue reuniones recurrentes y noches de capacitación.
- Añada los tipos de llamada que usan sus despachadores y defina sus prioridades. Dé a cada tipo de llamada una clase y a cada prioridad un significado para que los informes y las aplicaciones puedan agruparlos y ordenarlos. Un tipo de llamada también puede tener un icono personalizado, que se muestra junto a sus llamadas en listas, las aplicaciones y el mapa.
+ Añada los tipos de llamada que usan sus despachadores y defina sus prioridades. Dé a cada tipo de llamada una clase y a cada prioridad un significado para que los informes y las aplicaciones puedan agruparlos y ordenarlos. Un tipo de llamada también puede tener un icono personalizado, que se muestra junto a sus llamadas en listas, las aplicaciones y el mapa. El significado de una prioridad también colorea los marcadores de sus llamadas en el mapa, y la leyenda de iconos del mapa muestra cada prioridad bajo su color.
Registre las certificaciones actuales de los miembros y luego revise cuáles vencen pronto.
@@ -1654,7 +1654,7 @@
Agregue sus contratos de servicio y sus tarifas.
- Revise los estados integrados y añada los suyos donde sus términos difieran. Dé a cada opción un significado (tipo base): el despacho, la disponibilidad, las notificaciones y las aplicaciones leen el significado, no el texto del botón. En el cuartel indica a las recomendaciones de despacho que una unidad está en su estación.
+ Revise los estados integrados y añada los suyos donde sus términos difieran. Dé a cada opción un significado (tipo base): el despacho, la disponibilidad, las notificaciones y las aplicaciones leen el significado, no el texto del botón. En el cuartel indica a las recomendaciones de despacho que una unidad está en su estación. La leyenda de iconos del mapa muestra cada estado bajo el color de mapa que le da su significado; uno que aparece en gris aún no tiene significado.
Revise la lista de verificación de inscripción y planifique la recuperación de claves antes de inscribirse.
@@ -7075,7 +7075,16 @@
Iconos propios para tipos de unidad, tipos de llamada, puntos de interés y roles de personal, que se muestran en listas, las aplicaciones y el mapa.
- Suba un PNG cuadrado con fondo transparente, de 128 × 128 px o más, en la página del tipo de unidad, tipo de llamada, tipo de POI o rol; la página muestra una vista previa en un mapa claro y uno oscuro. Para elegir el icono de una persona con varios roles, ordene los roles en la página Roles.
+ Suba un PNG cuadrado con fondo transparente, de 128 × 128 px o más, en la página del tipo de unidad, tipo de llamada, tipo de POI o rol; la página muestra una vista previa en un mapa claro y uno oscuro. Para elegir el icono de una persona con varios roles, ordene los roles en la página Roles. Use Iconos del mapa por estado o Iconos del mapa por prioridad en la misma página para añadir un icono para cada estado o prioridad.
+
+
+ Leyenda de iconos del mapa
+
+
+ Todos los iconos que el mapa muestra para llamadas, unidades y personal en cada prioridad y estado, con los colores de estado y de prioridad.
+
+
+ Abra Mapa y luego Leyenda de iconos del mapa. Los administradores del departamento pueden elegir Editar iconos en un tipo de llamada, tipo de unidad o rol para subir un icono para una prioridad o un estado. En las aplicaciones Responder, Unit e IC, toque el botón de leyenda en el mapa para ver la misma leyenda.
Visualizaciones del mapa
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx
index b746a3a7f..f77dc1e01 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.fr.resx
@@ -1624,7 +1624,7 @@
Ajoutez les réunions récurrentes et les soirées de formation.
- Ajoutez les types d'appel utilisés par vos répartiteurs et définissez leurs priorités. Donnez à chaque type d'appel une classe et à chaque priorité une signification pour que les rapports et les applications puissent les regrouper et les trier. Un type d'appel peut aussi avoir une icône personnalisée, affichée à côté de ses appels dans les listes, les applications et sur la carte.
+ Ajoutez les types d'appel utilisés par vos répartiteurs et définissez leurs priorités. Donnez à chaque type d'appel une classe et à chaque priorité une signification pour que les rapports et les applications puissent les regrouper et les trier. Un type d'appel peut aussi avoir une icône personnalisée, affichée à côté de ses appels dans les listes, les applications et sur la carte. La signification d'une priorité colore aussi les repères de ses appels sur la carte, et la légende des icônes de la carte range chaque priorité sous sa couleur.
Enregistrez les certifications actuelles des membres, puis vérifiez ce qui expire prochainement.
@@ -1654,7 +1654,7 @@
Ajoutez vos contrats de service et leurs tarifs.
- Vérifiez les statuts intégrés et ajoutez les vôtres là où vos termes diffèrent. Donnez à chaque option une signification (type de base) : l'envoi, la disponibilité, les notifications et les applications lisent la signification, pas le texte du bouton. « À la caserne » indique aux recommandations d'envoi qu'une unité est à sa caserne.
+ Vérifiez les statuts intégrés et ajoutez les vôtres là où vos termes diffèrent. Donnez à chaque option une signification (type de base) : l'envoi, la disponibilité, les notifications et les applications lisent la signification, pas le texte du bouton. « À la caserne » indique aux recommandations d'envoi qu'une unité est à sa caserne. La légende des icônes de la carte range chaque statut sous la couleur que lui donne sa signification ; un statut rangé sous le gris n'a pas encore de signification.
Consultez la liste de contrôle d'inscription et planifiez la récupération de clé avant de vous inscrire.
@@ -7075,7 +7075,16 @@
Vos propres icônes pour les types d'unités, les types d'appels, les points d'intérêt et les rôles du personnel, affichées dans les listes, les applications et sur la carte.
- Téléversez un PNG carré à fond transparent, de 128 × 128 px ou plus, sur la page du type d'unité, du type d'appel, du type de POI ou du rôle ; la page en affiche un aperçu sur une carte claire et une carte sombre. Pour choisir l'icône d'une personne ayant plusieurs rôles, ordonnez les rôles sur la page Rôles.
+ Téléversez un PNG carré à fond transparent, de 128 × 128 px ou plus, sur la page du type d'unité, du type d'appel, du type de POI ou du rôle ; la page en affiche un aperçu sur une carte claire et une carte sombre. Pour choisir l'icône d'une personne ayant plusieurs rôles, ordonnez les rôles sur la page Rôles. Utilisez Icônes de carte par statut ou Icônes de carte par priorité sur la même page pour ajouter une icône pour chaque statut ou priorité.
+
+
+ Légende des icônes de la carte
+
+
+ Toutes les icônes que la carte affiche pour les appels, les unités et le personnel selon chaque priorité et chaque statut, avec les couleurs des statuts et des priorités.
+
+
+ Ouvrez Carte, puis Légende des icônes de la carte. Les administrateurs du service peuvent choisir Modifier les icônes sur un type d'appel, un type d'unité ou un rôle pour téléverser une icône pour une priorité ou un statut. Dans les applications Responder, Unit et IC, touchez le bouton de légende sur la carte pour voir la même légende.
Visualisations cartographiques
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx
index e6918c983..48d3e4e8b 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.it.resx
@@ -1624,7 +1624,7 @@
Aggiungi riunioni ricorrenti e serate di formazione.
- Aggiungi i tipi di chiamata usati dai tuoi operatori e imposta le loro priorità. Dai a ogni tipo di chiamata una classe e a ogni priorità un significato, così report e app possono raggrupparli e ordinarli. Un tipo di chiamata può avere anche un'icona personalizzata, mostrata accanto alle sue chiamate negli elenchi, nelle app e sulla mappa.
+ Aggiungi i tipi di chiamata usati dai tuoi operatori e imposta le loro priorità. Dai a ogni tipo di chiamata una classe e a ogni priorità un significato, così report e app possono raggrupparli e ordinarli. Un tipo di chiamata può avere anche un'icona personalizzata, mostrata accanto alle sue chiamate negli elenchi, nelle app e sulla mappa. Il significato di una priorità colora anche i segnaposto delle sue chiamate sulla mappa, e la legenda delle icone della mappa elenca ogni priorità sotto il suo colore.
Registra le certificazioni attuali dei membri, quindi verifica cosa sta per scadere.
@@ -1654,7 +1654,7 @@
Aggiungi i tuoi contratti di servizio e le relative tariffe.
- Rivedi gli stati predefiniti e aggiungi i tuoi dove i tuoi termini sono diversi. Dai a ogni opzione un significato (tipo base): l'invio, la disponibilità, le notifiche e le app leggono il significato, non il testo del pulsante. In sede indica alle raccomandazioni di invio che un'unità è alla sua sede.
+ Rivedi gli stati predefiniti e aggiungi i tuoi dove i tuoi termini sono diversi. Dai a ogni opzione un significato (tipo base): l'invio, la disponibilità, le notifiche e le app leggono il significato, non il testo del pulsante. In sede indica alle raccomandazioni di invio che un'unità è alla sua sede. La legenda delle icone della mappa elenca ogni stato sotto il colore che gli dà il suo significato; uno elencato sotto il grigio non ha ancora un significato.
Esamina la lista di controllo per la registrazione e pianifica il recupero delle chiavi prima di registrarti.
@@ -7075,7 +7075,16 @@
Icone personalizzate per tipi di unità, tipi di chiamata, punti di interesse e ruoli del personale, mostrate negli elenchi, nelle app e sulla mappa.
- Carica un PNG quadrato con sfondo trasparente, di 128 × 128 px o più, nella pagina del tipo di unità, del tipo di chiamata, del tipo di POI o del ruolo; la pagina ne mostra l'anteprima su una mappa chiara e una scura. Per scegliere l'icona di una persona con più ruoli, ordina i ruoli nella pagina Ruoli.
+ Carica un PNG quadrato con sfondo trasparente, di 128 × 128 px o più, nella pagina del tipo di unità, del tipo di chiamata, del tipo di POI o del ruolo; la pagina ne mostra l'anteprima su una mappa chiara e una scura. Per scegliere l'icona di una persona con più ruoli, ordina i ruoli nella pagina Ruoli. Usa Icone della mappa per stato o Icone della mappa per priorità nella stessa pagina per aggiungere un'icona per ogni stato o priorità.
+
+
+ Legenda delle icone della mappa
+
+
+ Tutte le icone che la mappa mostra per chiamate, unità e personale in ogni priorità e stato, con i colori di stati e priorità.
+
+
+ Apri Mappa, poi Legenda delle icone della mappa. Gli amministratori del dipartimento possono scegliere Modifica icone su un tipo di chiamata, un tipo di unità o un ruolo per caricare un'icona per una priorità o uno stato. Nelle app Responder, Unit e IC, tocca il pulsante della legenda sulla mappa per vedere la stessa legenda.
Visualizzazioni mappa
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx
index 5e56759c5..bed3d4a8b 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.pl.resx
@@ -1624,7 +1624,7 @@
Dodaj cykliczne spotkania i wieczory szkoleniowe.
- Dodaj typy zgłoszeń używane przez dyspozytorów i ustaw ich priorytety. Nadaj każdemu typowi zgłoszenia klasę, a każdemu priorytetowi znaczenie, aby raporty i aplikacje mogły je grupować i sortować. Typ zgłoszenia może też mieć własną ikonę, wyświetlaną obok jego zgłoszeń na listach, w aplikacjach i na mapie.
+ Dodaj typy zgłoszeń używane przez dyspozytorów i ustaw ich priorytety. Nadaj każdemu typowi zgłoszenia klasę, a każdemu priorytetowi znaczenie, aby raporty i aplikacje mogły je grupować i sortować. Typ zgłoszenia może też mieć własną ikonę, wyświetlaną obok jego zgłoszeń na listach, w aplikacjach i na mapie. Znaczenie priorytetu koloruje też pinezki jego zgłoszeń na mapie, a legenda ikon mapy pokazuje każdy priorytet pod jego kolorem.
Zarejestruj bieżące certyfikaty członków, a następnie sprawdź, co wkrótce wygasa.
@@ -1654,7 +1654,7 @@
Dodaj swoje umowy serwisowe i ich stawki.
- Przejrzyj wbudowane statusy i dodaj własne tam, gdzie Twoje nazewnictwo się różni. Nadaj każdej opcji znaczenie (typ bazowy): dysponowanie, dostępność, powiadomienia i aplikacje odczytują znaczenie, a nie tekst przycisku. „W remizie” informuje rekomendacje dysponowania, że jednostka jest w swojej remizie.
+ Przejrzyj wbudowane statusy i dodaj własne tam, gdzie Twoje nazewnictwo się różni. Nadaj każdej opcji znaczenie (typ bazowy): dysponowanie, dostępność, powiadomienia i aplikacje odczytują znaczenie, a nie tekst przycisku. „W remizie” informuje rekomendacje dysponowania, że jednostka jest w swojej remizie. Legenda ikon mapy pokazuje każdy status pod kolorem, który nadaje mu jego znaczenie; status pod kolorem szarym nie ma jeszcze znaczenia.
Przejrzyj listę kontrolną rejestracji i zaplanuj odzyskiwanie kluczy przed rejestracją.
@@ -7075,7 +7075,16 @@
Własne ikony dla typów jednostek, typów zgłoszeń, punktów zainteresowania i ról personelu, wyświetlane na listach, w aplikacjach i na mapie.
- Prześlij kwadratowy plik PNG z przezroczystym tłem, o wymiarach co najmniej 128 × 128 px, na stronie typu jednostki, typu zgłoszenia, typu POI lub roli; strona pokaże podgląd na jasnej i ciemnej mapie. Aby wybrać ikonę osoby z kilkoma rolami, ustaw kolejność ról na stronie Role.
+ Prześlij kwadratowy plik PNG z przezroczystym tłem, o wymiarach co najmniej 128 × 128 px, na stronie typu jednostki, typu zgłoszenia, typu POI lub roli; strona pokaże podgląd na jasnej i ciemnej mapie. Aby wybrać ikonę osoby z kilkoma rolami, ustaw kolejność ról na stronie Role. Użyj opcji Ikony mapy według statusu lub Ikony mapy według priorytetu na tej samej stronie, aby dodać ikonę dla każdego statusu lub priorytetu.
+
+
+ Legenda ikon mapy
+
+
+ Wszystkie ikony, które mapa pokazuje dla zgłoszeń, jednostek i personelu w każdym priorytecie i statusie, wraz z kolorami statusów i priorytetów.
+
+
+ Otwórz Mapę, a następnie Legendę ikon mapy. Administratorzy jednostki mogą wybrać Edytuj ikony przy typie zgłoszenia, typie jednostki lub roli, aby przesłać ikonę dla priorytetu lub statusu. W aplikacjach Responder, Unit i IC dotknij przycisku legendy na mapie, aby zobaczyć tę samą legendę.
Wizualizacje mapy
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx
index 90b1dcf7a..bc3c0ea3c 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.resx
@@ -1624,7 +1624,7 @@
Add recurring meetings and training nights.
- Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map.
+ Add the call types your dispatchers use and set their priorities. Give each call type a class and each priority a meaning so reports and the apps can group and sort them. A call type can also have a custom icon, shown beside its calls in lists, the apps and on the map. A priority's meaning also colors its calls' map pins, and the Map Icon Legend lists each priority under its color.
Record members' current certifications, then review what expires soon.
@@ -1654,7 +1654,7 @@
Add your service contracts and their rates.
- Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station.
+ Review the built-in statuses and add your own where your terms differ. Give every option a meaning (base type): dispatch, availability, notifications and the apps read the meaning, not the button text. In Quarters tells dispatch recommendations a unit is at its station. The Map Icon Legend lists each status under the map color its meaning gives it; one listed under gray has no meaning yet.
Review the enrollment checklist and plan key recovery before enrolling.
@@ -7021,7 +7021,16 @@
Your own icons for unit types, call types, points of interest and personnel roles, shown in lists, the apps and on the map.
- Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page.
+ Upload a square PNG with a transparent background, 128 × 128 px or larger, on the unit type, call type, POI type or role page; the page previews it on a light and a dark map. To choose which icon a person with several roles shows, order the roles on the Roles page. Use Status map icons or Priority map icons on the same page to add an icon for each status or priority.
+
+
+ Map icon legend
+
+
+ Every icon the map shows for calls, units and personnel in each priority and status, with the status and priority colors.
+
+
+ Open Mapping, then Map Icon Legend. Department admins can choose Edit icons on a call type, unit type or role to upload an icon for a priority or status. In the Responder, Unit and IC apps, tap the legend button on the map to see the same legend.
Map visualizations
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx
index 18faca624..8e5b1a68e 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.sv.resx
@@ -1624,7 +1624,7 @@
Lägg till återkommande möten och utbildningskvällar.
- Lägg till de ärendetyper era operatörer använder och ange deras prioriteter. Ge varje ärendetyp en klass och varje prioritet en betydelse så att rapporter och appar kan gruppera och sortera dem. En larmtyp kan också ha en egen ikon som visas bredvid dess larm i listor, apparna och på kartan.
+ Lägg till de ärendetyper era operatörer använder och ange deras prioriteter. Ge varje ärendetyp en klass och varje prioritet en betydelse så att rapporter och appar kan gruppera och sortera dem. En larmtyp kan också ha en egen ikon som visas bredvid dess larm i listor, apparna och på kartan. En prioritets betydelse färgar också dess larms kartnålar, och förklaringen till kartikoner visar varje prioritet under dess färg.
Registrera medlemmarnas aktuella certifieringar och granska sedan vad som snart går ut.
@@ -1654,7 +1654,7 @@
Lägg till dina tjänsteavtal och deras taxor.
- Granska de inbyggda statusarna och lägg till egna där era begrepp skiljer sig. Ge varje alternativ en betydelse (bastyp): larmning, tillgänglighet, aviseringar och apparna läser betydelsen, inte knapptexten. På stationen talar om för larmrekommendationerna att en enhet är på sin station.
+ Granska de inbyggda statusarna och lägg till egna där era begrepp skiljer sig. Ge varje alternativ en betydelse (bastyp): larmning, tillgänglighet, aviseringar och apparna läser betydelsen, inte knapptexten. På stationen talar om för larmrekommendationerna att en enhet är på sin station. Förklaringen till kartikoner visar varje status under den kartfärg som dess betydelse ger; en status under grått saknar ännu betydelse.
Granska checklistan för anslutning och planera nyckelåterställning innan ni ansluter er.
@@ -7075,7 +7075,16 @@
Egna ikoner för enhetstyper, larmtyper, intressepunkter och personalroller som visas i listor, apparna och på kartan.
- Ladda upp en kvadratisk PNG med genomskinlig bakgrund, 128 × 128 px eller större, på sidan för enhetstypen, larmtypen, POI-typen eller rollen; sidan förhandsgranskar den på en ljus och en mörk karta. Ordna rollerna på sidan Roller för att välja vilken ikon en person med flera roller får.
+ Ladda upp en kvadratisk PNG med genomskinlig bakgrund, 128 × 128 px eller större, på sidan för enhetstypen, larmtypen, POI-typen eller rollen; sidan förhandsgranskar den på en ljus och en mörk karta. Ordna rollerna på sidan Roller för att välja vilken ikon en person med flera roller får. Använd Kartikoner per status eller Kartikoner per prioritet på samma sida för att lägga till en ikon för varje status eller prioritet.
+
+
+ Förklaring till kartikoner
+
+
+ Alla ikoner som kartan visar för larm, enheter och personal i varje prioritet och status, med status- och prioritetsfärgerna.
+
+
+ Öppna Karta och sedan Förklaring till kartikoner. Avdelningsadministratörer kan välja Redigera ikoner på en larmtyp, enhetstyp eller roll för att ladda upp en ikon för en prioritet eller status. I apparna Responder, Unit och IC trycker du på förklaringsknappen på kartan för att se samma förklaring.
Kartvisualiseringar
diff --git a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx
index c0b155220..4b541a326 100644
--- a/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/AdminAssist/AdminAssist.uk.resx
@@ -1624,7 +1624,7 @@
Додайте регулярні зустрічі й навчальні вечори.
- Додайте типи викликів, якими користуються ваші диспетчери, і встановіть їхні пріоритети. Надайте кожному типу виклику клас, а кожному пріоритету значення, щоб звіти й застосунки могли їх групувати та сортувати. Тип виклику також може мати власну іконку, що показується поруч із його викликами в списках, застосунках і на карті.
+ Додайте типи викликів, якими користуються ваші диспетчери, і встановіть їхні пріоритети. Надайте кожному типу виклику клас, а кожному пріоритету значення, щоб звіти й застосунки могли їх групувати та сортувати. Тип виклику також може мати власну іконку, що показується поруч із його викликами в списках, застосунках і на карті. Значення пріоритету також фарбує позначки його викликів на карті, а легенда значків карти показує кожен пріоритет під його кольором.
Зафіксуйте поточні сертифікати учасників, а потім перегляньте, строк дії яких незабаром закінчується.
@@ -1654,7 +1654,7 @@
Додайте свої договори про надання послуг та їхні тарифи.
- Перегляньте вбудовані статуси та додайте власні там, де ваші терміни відрізняються. Надайте кожному параметру значення (базовий тип): відправлення, доступність, сповіщення та застосунки читають значення, а не текст кнопки. «На станції» повідомляє рекомендаціям відправлення, що підрозділ на своїй станції.
+ Перегляньте вбудовані статуси та додайте власні там, де ваші терміни відрізняються. Надайте кожному параметру значення (базовий тип): відправлення, доступність, сповіщення та застосунки читають значення, а не текст кнопки. «На станції» повідомляє рекомендаціям відправлення, що підрозділ на своїй станції. Легенда значків карти показує кожен статус під кольором, який дає йому його значення; статус під сірим ще не має значення.
Перегляньте контрольний список підключення та сплануйте відновлення ключів перед підключенням.
@@ -7075,7 +7075,16 @@
Власні іконки для типів підрозділів, типів викликів, точок інтересу й ролей персоналу, що показуються в списках, застосунках і на карті.
- Завантажте квадратний PNG із прозорим тлом розміром від 128 × 128 px на сторінці типу підрозділу, типу виклику, типу POI або ролі; сторінка покаже попередній перегляд на світлій і темній карті. Щоб вибрати іконку людини з кількома ролями, упорядкуйте ролі на сторінці «Ролі».
+ Завантажте квадратний PNG із прозорим тлом розміром від 128 × 128 px на сторінці типу підрозділу, типу виклику, типу POI або ролі; сторінка покаже попередній перегляд на світлій і темній карті. Щоб вибрати іконку людини з кількома ролями, упорядкуйте ролі на сторінці «Ролі». Скористайтеся «Значки карти за статусом» або «Значки карти за пріоритетом» на тій самій сторінці, щоб додати значок для кожного статусу чи пріоритету.
+
+
+ Легенда значків карти
+
+
+ Усі значки, які карта показує для викликів, підрозділів і персоналу в кожному пріоритеті та статусі, з кольорами статусів і пріоритетів.
+
+
+ Відкрийте «Карта», потім «Легенда значків карти». Адміністратори підрозділу можуть вибрати «Редагувати значки» для типу виклику, типу підрозділу або ролі, щоб завантажити значок для пріоритету чи статусу. У застосунках Responder, Unit та IC торкніться кнопки легенди на карті, щоб побачити ту саму легенду.
Візуалізації на карті
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
index b937d1617..a3ffd40c0 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.ar.resx
@@ -1782,6 +1782,12 @@
{0} (محفوظ سابقًا، لم يعد متاحًا)
+
+ تم حفظ هذا الاختيار سابقًا ولم يعد متاحًا. تقوم إعادة التعيين اليومية حاليًا بتعيين الجميع إلى {0}. اختر معنى أو أحد خياراتك الخاصة واحفظ لتغييره.
+
+
+ تم حفظ هذا الاختيار سابقًا ولا يطابق أي شيء تستخدمه الإدارة، لذا لا تغيّر إعادة التعيين اليومية أحدًا. اختر معنى أو أحد خياراتك الخاصة واحفظ.
+
تخفيف عوامل تصفية الحالة للصفوف غير المكتملة
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
index 320eea802..1f15249db 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.de.resx
@@ -1724,6 +1724,12 @@
{0} (früher gespeichert, nicht mehr angeboten)
+
+ Diese Auswahl wurde früher gespeichert und wird nicht mehr angeboten. Das tägliche Zurücksetzen setzt derzeit alle auf {0}. Wählen Sie eine Bedeutung oder eine Ihrer eigenen Optionen und speichern Sie, um das zu ändern.
+
+
+ Diese Auswahl wurde früher gespeichert und passt zu nichts, was die Abteilung verwendet, daher ändert das tägliche Zurücksetzen niemanden. Wählen Sie eine Bedeutung oder eine Ihrer eigenen Optionen und speichern Sie.
+
Statusfilter bei offenen Zeilen lockern
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx
index 181657674..5e689afd1 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.el.resx
@@ -1782,6 +1782,12 @@
{0} (αποθηκεύτηκε νωρίτερα, δεν προσφέρεται πια)
+
+ Αυτή η επιλογή αποθηκεύτηκε παλαιότερα και δεν προσφέρεται πλέον. Η ημερήσια επαναφορά ορίζει αυτή τη στιγμή όλους σε {0}. Επιλέξτε μια σημασία ή μία από τις δικές σας επιλογές και αποθηκεύστε για να το αλλάξετε.
+
+
+ Αυτή η επιλογή αποθηκεύτηκε παλαιότερα και δεν αντιστοιχεί σε τίποτα από όσα χρησιμοποιεί το τμήμα, οπότε η ημερήσια επαναφορά δεν αλλάζει κανέναν. Επιλέξτε μια σημασία ή μία από τις δικές σας επιλογές και αποθηκεύστε.
+
Χαλάρωση φίλτρων κατάστασης για μη καλυμμένες γραμμές
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
index 596d3ff8d..76a1341c3 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
@@ -1782,6 +1782,12 @@
{0} (saved earlier, no longer offered)
+
+ This choice was saved earlier and is no longer offered. The daily reset currently sets everyone to {0}. Pick a meaning or one of your own options and save to change it.
+
+
+ This choice was saved earlier and matches nothing the department uses, so the daily reset changes nobody. Pick a meaning or one of your own options and save.
+
Relax status filters for unfilled rows
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
index 4a77bbf3d..89918fe77 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.es.resx
@@ -1782,6 +1782,12 @@
{0} (guardado antes, ya no disponible)
+
+ Esta opción se guardó anteriormente y ya no se ofrece. El restablecimiento diario establece actualmente a todos en {0}. Elija un significado o una de sus propias opciones y guarde para cambiarlo.
+
+
+ Esta opción se guardó anteriormente y no coincide con nada que use el departamento, por lo que el restablecimiento diario no cambia a nadie. Elija un significado o una de sus propias opciones y guarde.
+
Relajar los filtros de estado en filas sin cubrir
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
index fbce0dbe3..78d1b2f53 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.fr.resx
@@ -1724,6 +1724,12 @@
{0} (enregistré auparavant, plus proposé)
+
+ Ce choix a été enregistré auparavant et n'est plus proposé. La réinitialisation quotidienne définit actuellement tout le monde sur {0}. Choisissez une signification ou l'une de vos propres options et enregistrez pour le modifier.
+
+
+ Ce choix a été enregistré auparavant et ne correspond à rien de ce qu'utilise le service, donc la réinitialisation quotidienne ne modifie personne. Choisissez une signification ou l'une de vos propres options et enregistrez.
+
Assouplir les filtres de statut des lignes non pourvues
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
index 07c9a7e2f..66a4f65b1 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.it.resx
@@ -1724,6 +1724,12 @@
{0} (salvato in precedenza, non più disponibile)
+
+ Questa scelta è stata salvata in precedenza e non è più disponibile. Il ripristino giornaliero imposta attualmente tutti su {0}. Scegli un significato o una delle tue opzioni e salva per modificarlo.
+
+
+ Questa scelta è stata salvata in precedenza e non corrisponde a nulla di ciò che il dipartimento utilizza, quindi il ripristino giornaliero non modifica nessuno. Scegli un significato o una delle tue opzioni e salva.
+
Allenta i filtri di stato per le righe non coperte
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
index 2b817f16c..690f2a54e 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.pl.resx
@@ -1724,6 +1724,12 @@
{0} (zapisany wcześniej, już niedostępny)
+
+ Ten wybór został zapisany wcześniej i nie jest już oferowany. Codzienny reset ustawia obecnie wszystkich na {0}. Wybierz znaczenie lub jedną z własnych opcji i zapisz, aby to zmienić.
+
+
+ Ten wybór został zapisany wcześniej i nie pasuje do niczego, czego używa jednostka, więc codzienny reset nikogo nie zmienia. Wybierz znaczenie lub jedną z własnych opcji i zapisz.
+
Złagodź filtry statusu dla niewypełnionych wierszy
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
index 921e61a39..6c3a63757 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
@@ -1377,6 +1377,12 @@
{0} (saved earlier, no longer offered)
+
+ This choice was saved earlier and is no longer offered. The daily reset currently sets everyone to {0}. Pick a meaning or one of your own options and save to change it.
+
+
+ This choice was saved earlier and matches nothing the department uses, so the daily reset changes nobody. Pick a meaning or one of your own options and save.
+
Relax status filters for unfilled rows
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
index e82f2bcb4..a40f2ce0b 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.sv.resx
@@ -1724,6 +1724,12 @@
{0} (sparad tidigare, erbjuds inte längre)
+
+ Det här valet sparades tidigare och erbjuds inte längre. Den dagliga återställningen sätter för närvarande alla till {0}. Välj en betydelse eller ett av dina egna alternativ och spara för att ändra det.
+
+
+ Det här valet sparades tidigare och matchar inget som avdelningen använder, så den dagliga återställningen ändrar ingen. Välj en betydelse eller ett av dina egna alternativ och spara.
+
Lätta på statusfilter för ofyllda rader
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
index 19e4dafec..4f60f86b2 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.uk.resx
@@ -1724,6 +1724,12 @@
{0} (збережено раніше, більше не пропонується)
+
+ Цей вибір було збережено раніше, і він більше не пропонується. Щоденне скидання зараз встановлює всім {0}. Виберіть значення або один зі своїх варіантів і збережіть, щоб змінити це.
+
+
+ Цей вибір було збережено раніше, і він не відповідає нічому, що використовує підрозділ, тому щоденне скидання нікого не змінює. Виберіть значення або один зі своїх варіантів і збережіть.
+
Послаблювати фільтри статусу для незаповнених рядків
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
index a4747ed89..bb3500ad2 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.ar.resx
@@ -526,10 +526,10 @@
على {0}
- الوحدات
+ الوحدات المتاحة
- الأفراد
+ الأفراد المتاحون
حدود المحطات
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
index 355bf339e..b1476b6b4 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.de.resx
@@ -1084,10 +1084,10 @@
Auf {0}
- Einheiten
+ Verfügbare Einheiten
- Personal
+ Verfügbares Personal
Wachbereiche
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
index a0a8c5042..4c34b7895 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.el.resx
@@ -996,10 +996,10 @@
Στο {0}
- Μονάδες
+ Διαθέσιμες μονάδες
- Προσωπικό
+ Διαθέσιμο προσωπικό
Όρια σταθμών
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
index b8dc29fbd..79add84f8 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.en.resx
@@ -996,10 +996,10 @@
On {0}
- Units
+ Available units
- Personnel
+ Available personnel
Station boundaries
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
index b77ccabf5..ed74d5ef5 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.es.resx
@@ -1142,10 +1142,10 @@
En {0}
- Unidades
+ Unidades disponibles
- Personal
+ Personal disponible
Límites de estaciones
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
index c5d229969..2e03578f8 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.fr.resx
@@ -1084,10 +1084,10 @@
Sur {0}
- Unités
+ Unités disponibles
- Personnel
+ Personnel disponible
Secteurs des casernes
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
index 8cfebb311..83ba1a06e 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.it.resx
@@ -1084,10 +1084,10 @@
Su {0}
- Unità
+ Unità disponibili
- Personale
+ Personale disponibile
Confini delle sedi
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
index ec7acb53d..d9123f360 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.pl.resx
@@ -1084,10 +1084,10 @@
Na {0}
- Jednostki
+ Dostępne jednostki
- Personel
+ Dostępny personel
Granice rejonów
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
index 8b1b8c1d4..c2aba57f8 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.sv.resx
@@ -1084,10 +1084,10 @@
På {0}
- Enheter
+ Tillgängliga enheter
- Personal
+ Tillgänglig personal
Stationsområden
diff --git a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
index d98a093ba..9dfb162e1 100644
--- a/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Dispatch/Call.uk.resx
@@ -1084,10 +1084,10 @@
На {0}
- Підрозділи
+ Доступні підрозділи
- Персонал
+ Доступний персонал
Межі станцій
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.ar.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.ar.resx
new file mode 100644
index 000000000..a404d56ae
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.ar.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ دليل رموز الخريطة
+
+
+ كل الرموز التي تعرضها الخريطة للبلاغات والوحدات والأفراد. يتبع رمز البلاغ نوعه وأولويته، ورمز الوحدة نوعها وحالتها، ورمز العضو دوره وحالته. لا تغيّر مستويات الجاهزية أي رمز أبدًا.
+
+
+ كيف يُختار الرمز
+
+
+ رمز مرفوع للنوع أو الدور لهذه الأولوية أو الحالة تحديدًا.
+
+
+ وإلا فالرمز المرفوع الخاص بالنوع أو الدور، مع شارة بلون الأولوية أو الحالة.
+
+
+ وإلا فالدبوس المدمج بلون الأولوية أو الحالة.
+
+
+ تُلوَّن الأولوية أو الحالة التي لم يُضبط لها معنى بحسب ما يوحي به اسمها، مثل طارئة أو خارج الخدمة؛ وإن لم يوحِ الاسم بشيء يظهر دبوس رمادي، وكذلك للوحدة أو العضو بلا حالة. اضبط المعاني في صفحتي الحالات المخصصة وأولويات البلاغات للتأكد.
+
+
+ ألوان الحالات
+
+
+ تأخذ الوحدات والأفراد لون معنى حالتهم.
+
+
+ ألوان الأولويات
+
+
+ تأخذ البلاغات لون معنى أولويتها.
+
+
+ متاح
+
+
+ مُرسَل
+
+
+ يستجيب
+
+
+ في الموقع
+
+
+ ينقل
+
+
+ عائد
+
+
+ تجمّع واستعداد
+
+
+ في استراحة
+
+
+ غير متاح
+
+
+ بلا معنى أو بلا حالة
+
+
+ منخفضة
+
+
+ متوسطة
+
+
+ عالية
+
+
+ طارئة
+
+
+ البلاغات
+
+
+ صف لكل نوع بلاغ، وعمود لكل أولوية.
+
+
+ الوحدات
+
+
+ صف لكل نوع وحدة، مع حالات مجموعة حالاته.
+
+
+ الأفراد
+
+
+ يعرض العضو رمز أول دور يشغله، بترتيب الأدوار، له رمز. ويأخذ العضو «على وحدة» لون حالة وحدته.
+
+
+ المحطات وصنابير الإطفاء ونقاط الاهتمام
+
+
+ لا تتغير هذه الرموز بتغير الحالة.
+
+
+ بلاغات بلا نوع
+
+
+ وحدات بلا نوع
+
+
+ أعضاء بلا رمز دور
+
+
+ حالات الوحدات المدمجة
+
+
+ محطة
+
+
+ صنبور إطفاء
+
+
+ تعديل الرموز
+
+
+ المفتاح
+
+
+ مرفوع لهذه الحالة أو الأولوية
+
+
+ رمز النوع أو الدور مع شارة الحالة أو الأولوية
+
+
+ دبوس مدمج
+
+
+ بلا معنى: دبوس رمادي
+
+
+ النوع
+
+
+ الدور
+
+
+ مجموعة الحالات
+
+
+ رموز الخريطة لـ {0}
+
+
+ ارفع رمزًا لحالة لعرضه على الخريطة ما دامت وحدة من هذا النوع في تلك الحالة. وبدونه يُستخدم الرمز الافتراضي المعروض.
+
+
+ ارفع رمزًا لأولوية لعرضه على الخريطة ما دام بلاغ من هذا النوع بتلك الأولوية. وبدونه يُستخدم الرمز الافتراضي المعروض.
+
+
+ ارفع رمزًا لحالة لعرضه على الخريطة ما دام عضو يأتي رمزه من هذا الدور في تلك الحالة. وبدونه يُستخدم الرمز الافتراضي المعروض.
+
+
+ الرمز دون حالة أو أولوية
+
+
+ الحالة
+
+
+ الأولوية
+
+
+ لون الخريطة
+
+
+ الافتراضي
+
+
+ الرمز المرفوع
+
+
+ رفع
+
+
+ إزالة
+
+
+ لا يوجد
+
+
+ تم حفظ الرمز.
+
+
+ تمت إزالة الرمز.
+
+
+ رموز الخريطة حسب الحالة
+
+
+ رموز الخريطة حسب الأولوية
+
+
+ رمز خريطة مختلف لكل حالة، ليسهل تمييز الوحدة أو العضو بنظرة.
+
+
+ رمز خريطة مختلف لكل أولوية، ليسهل تمييز البلاغ بنظرة.
+
+
+ حالات الوحدات لديكم: {0}
+
+
+ حالات الأفراد لديكم: {0}
+
+
+ أولوياتكم: {0}
+
+
+ يأخذ لون حالة وحدته
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.cs b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.cs
new file mode 100644
index 000000000..e52ba4db0
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.cs
@@ -0,0 +1,7 @@
+namespace Resgrid.Localization.Areas.User.Mapping
+{
+ /// Text for Mapping → Map Icon Legend and the status and priority map icon pages of unit types, call types and roles.
+ public class MapIconLegend
+ {
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.de.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.de.resx
new file mode 100644
index 000000000..b3f981b53
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.de.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Legende der Kartensymbole
+
+
+ Alle Symbole, die die Karte für Einsätze, Einheiten und Personal zeigt. Das Symbol eines Einsatzes richtet sich nach Typ und Priorität, das einer Einheit nach Typ und Status und das eines Mitglieds nach Rolle und Status. Die Personalstärke ändert nie ein Symbol.
+
+
+ So wird ein Symbol gewählt
+
+
+ Ein für den Typ oder die Rolle genau für diese Priorität oder diesen Status hochgeladenes Symbol.
+
+
+ Sonst das eigene hochgeladene Symbol des Typs oder der Rolle, mit einem Abzeichen in der Farbe der Priorität oder des Status.
+
+
+ Sonst die eingebaute Markierung in der Farbe der Priorität oder des Status.
+
+
+ Eine Priorität oder ein Status ohne festgelegte Bedeutung wird nach dem gefärbt, was ihr Name nahelegt, etwa „Notfall“ oder „Außer Dienst“; legt der Name nichts nahe, erscheint eine graue Markierung, ebenso bei einer Einheit oder einem Mitglied ohne Status. Legen Sie Bedeutungen auf den Seiten Benutzerdefinierte Status und Einsatzprioritäten fest, um sicherzugehen.
+
+
+ Statusfarben
+
+
+ Einheiten und Personal nehmen die Farbe der Bedeutung ihres Status an.
+
+
+ Prioritätsfarben
+
+
+ Einsätze nehmen die Farbe der Bedeutung ihrer Priorität an.
+
+
+ Verfügbar
+
+
+ Alarmiert
+
+
+ Auf Anfahrt
+
+
+ Vor Ort
+
+
+ Transport
+
+
+ Rückfahrt
+
+
+ Bereitstellung und Bereitschaft
+
+
+ Pause
+
+
+ Nicht verfügbar
+
+
+ Keine Bedeutung oder kein Status
+
+
+ Niedrig
+
+
+ Mittel
+
+
+ Hoch
+
+
+ Notfall
+
+
+ Einsätze
+
+
+ Eine Zeile je Einsatztyp, eine Spalte je Priorität.
+
+
+ Einheiten
+
+
+ Eine Zeile je Einheitentyp, mit den Status seines Statussatzes.
+
+
+ Personal
+
+
+ Ein Mitglied zeigt das Symbol der ersten seiner Rollen in Rollenreihenfolge, die eines hat. Ein Mitglied „Auf Einheit“ nimmt die Farbe des Status seiner Einheit an.
+
+
+ Wachen, Hydranten und Points of Interest
+
+
+ Diese Symbole ändern sich nicht mit dem Status.
+
+
+ Einsätze ohne Typ
+
+
+ Einheiten ohne Typ
+
+
+ Mitglieder ohne Rollensymbol
+
+
+ Eingebaute Einheitenstatus
+
+
+ Wache
+
+
+ Hydrant
+
+
+ Symbole bearbeiten
+
+
+ Zeichenerklärung
+
+
+ Für diesen Status oder diese Priorität hochgeladen
+
+
+ Typ- oder Rollensymbol mit Status- oder Prioritätsabzeichen
+
+
+ Eingebaute Markierung
+
+
+ Keine Bedeutung: graue Markierung
+
+
+ Typ
+
+
+ Rolle
+
+
+ Statussatz
+
+
+ Kartensymbole für {0}
+
+
+ Laden Sie für einen Status ein Symbol hoch, um es auf der Karte zu zeigen, solange eine Einheit dieses Typs in diesem Status ist. Ohne eines wird die gezeigte Vorgabe verwendet.
+
+
+ Laden Sie für eine Priorität ein Symbol hoch, um es auf der Karte zu zeigen, solange ein Einsatz dieses Typs diese Priorität hat. Ohne eines wird die gezeigte Vorgabe verwendet.
+
+
+ Laden Sie für einen Status ein Symbol hoch, um es auf der Karte zu zeigen, solange ein Mitglied, dessen Symbol von dieser Rolle kommt, in diesem Status ist. Ohne eines wird die gezeigte Vorgabe verwendet.
+
+
+ Symbol ohne Status oder Priorität
+
+
+ Status
+
+
+ Priorität
+
+
+ Kartenfarbe
+
+
+ Vorgabe
+
+
+ Hochgeladenes Symbol
+
+
+ Hochladen
+
+
+ Entfernen
+
+
+ Keines
+
+
+ Symbol gespeichert.
+
+
+ Symbol entfernt.
+
+
+ Kartensymbole nach Status
+
+
+ Kartensymbole nach Priorität
+
+
+ Ein anderes Kartensymbol für jeden Status, damit eine Einheit oder ein Mitglied auf einen Blick erkennbar ist.
+
+
+ Ein anderes Kartensymbol für jede Priorität, damit ein Einsatz auf einen Blick erkennbar ist.
+
+
+ Status Ihrer Einheiten: {0}
+
+
+ Status Ihres Personals: {0}
+
+
+ Ihre Prioritäten: {0}
+
+
+ Nimmt die Farbe des Status seiner Einheit an
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.el.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.el.resx
new file mode 100644
index 000000000..40991b95a
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.el.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Υπόμνημα εικονιδίων χάρτη
+
+
+ Όλα τα εικονίδια που δείχνει ο χάρτης για κλήσεις, μονάδες και προσωπικό. Το εικονίδιο μιας κλήσης ακολουθεί τον τύπο και την προτεραιότητά της, μιας μονάδας τον τύπο και την κατάστασή της και ενός μέλους τον ρόλο και την κατάστασή του. Τα επίπεδα στελέχωσης δεν αλλάζουν ποτέ ένα εικονίδιο.
+
+
+ Πώς επιλέγεται ένα εικονίδιο
+
+
+ Ένα εικονίδιο που ανέβηκε για τον τύπο ή τον ρόλο για τη συγκεκριμένη προτεραιότητα ή κατάσταση.
+
+
+ Αλλιώς το δικό του ανεβασμένο εικονίδιο του τύπου ή του ρόλου, με ένα σήμα στο χρώμα της προτεραιότητας ή της κατάστασης.
+
+
+ Αλλιώς η ενσωματωμένη πινέζα στο χρώμα της προτεραιότητας ή της κατάστασης.
+
+
+ Μια προτεραιότητα ή κατάσταση χωρίς ορισμένη σημασία χρωματίζεται σύμφωνα με ό,τι υποδηλώνει το όνομά της, όπως Έκτακτη ανάγκη ή Εκτός υπηρεσίας· αν το όνομα δεν υποδηλώνει τίποτα, εμφανίζεται γκρι πινέζα, όπως και για μονάδα ή μέλος χωρίς κατάσταση. Ορίστε σημασίες στις σελίδες Προσαρμοσμένες καταστάσεις και Προτεραιότητες κλήσεων για να είστε σίγουροι.
+
+
+ Χρώματα καταστάσεων
+
+
+ Οι μονάδες και το προσωπικό παίρνουν το χρώμα της σημασίας της κατάστασής τους.
+
+
+ Χρώματα προτεραιοτήτων
+
+
+ Οι κλήσεις παίρνουν το χρώμα της σημασίας της προτεραιότητάς τους.
+
+
+ Διαθέσιμος
+
+
+ Σε αποστολή
+
+
+ Σε ανταπόκριση
+
+
+ Επί τόπου
+
+
+ Σε μεταφορά
+
+
+ Σε επιστροφή
+
+
+ Συγκέντρωση και αναμονή
+
+
+ Σε διάλειμμα
+
+
+ Μη διαθέσιμος
+
+
+ Χωρίς σημασία ή χωρίς κατάσταση
+
+
+ Χαμηλή
+
+
+ Μεσαία
+
+
+ Υψηλή
+
+
+ Έκτακτη ανάγκη
+
+
+ Κλήσεις
+
+
+ Μία γραμμή ανά τύπο κλήσης, μία στήλη ανά προτεραιότητα.
+
+
+ Μονάδες
+
+
+ Μία γραμμή ανά τύπο μονάδας, με τις καταστάσεις του συνόλου καταστάσεών του.
+
+
+ Προσωπικό
+
+
+ Ένα μέλος δείχνει το εικονίδιο του πρώτου ρόλου του, κατά τη σειρά ρόλων, που έχει εικονίδιο. Ένα μέλος «Σε μονάδα» παίρνει το χρώμα της κατάστασης της μονάδας του.
+
+
+ Σταθμοί, κρουνοί και σημεία ενδιαφέροντος
+
+
+ Αυτά τα εικονίδια δεν αλλάζουν με την κατάσταση.
+
+
+ Κλήσεις χωρίς τύπο
+
+
+ Μονάδες χωρίς τύπο
+
+
+ Μέλη χωρίς εικονίδιο ρόλου
+
+
+ Ενσωματωμένες καταστάσεις μονάδων
+
+
+ Σταθμός
+
+
+ Κρουνός
+
+
+ Επεξεργασία εικονιδίων
+
+
+ Επεξήγηση
+
+
+ Ανέβηκε για αυτή την κατάσταση ή προτεραιότητα
+
+
+ Εικονίδιο τύπου ή ρόλου με σήμα κατάστασης ή προτεραιότητας
+
+
+ Ενσωματωμένη πινέζα
+
+
+ Χωρίς σημασία: γκρι πινέζα
+
+
+ Τύπος
+
+
+ Ρόλος
+
+
+ Σύνολο καταστάσεων
+
+
+ Εικονίδια χάρτη για {0}
+
+
+ Ανεβάστε ένα εικονίδιο για μια κατάσταση ώστε να εμφανίζεται στον χάρτη όσο μια μονάδα αυτού του τύπου βρίσκεται σε αυτή. Χωρίς αυτό χρησιμοποιείται το προεπιλεγμένο που φαίνεται.
+
+
+ Ανεβάστε ένα εικονίδιο για μια προτεραιότητα ώστε να εμφανίζεται στον χάρτη όσο μια κλήση αυτού του τύπου έχει αυτή την προτεραιότητα. Χωρίς αυτό χρησιμοποιείται το προεπιλεγμένο που φαίνεται.
+
+
+ Ανεβάστε ένα εικονίδιο για μια κατάσταση ώστε να εμφανίζεται στον χάρτη όσο ένα μέλος του οποίου το εικονίδιο προέρχεται από αυτόν τον ρόλο βρίσκεται σε αυτή. Χωρίς αυτό χρησιμοποιείται το προεπιλεγμένο που φαίνεται.
+
+
+ Εικονίδιο χωρίς κατάσταση ή προτεραιότητα
+
+
+ Κατάσταση
+
+
+ Προτεραιότητα
+
+
+ Χρώμα χάρτη
+
+
+ Προεπιλογή
+
+
+ Ανεβασμένο εικονίδιο
+
+
+ Ανέβασμα
+
+
+ Αφαίρεση
+
+
+ Κανένα
+
+
+ Το εικονίδιο αποθηκεύτηκε.
+
+
+ Το εικονίδιο αφαιρέθηκε.
+
+
+ Εικονίδια χάρτη ανά κατάσταση
+
+
+ Εικονίδια χάρτη ανά προτεραιότητα
+
+
+ Διαφορετικό εικονίδιο χάρτη για κάθε κατάσταση, ώστε μια μονάδα ή ένα μέλος να διαβάζεται με μια ματιά.
+
+
+ Διαφορετικό εικονίδιο χάρτη για κάθε προτεραιότητα, ώστε μια κλήση να διαβάζεται με μια ματιά.
+
+
+ Οι καταστάσεις μονάδων σας: {0}
+
+
+ Οι καταστάσεις προσωπικού σας: {0}
+
+
+ Οι προτεραιότητές σας: {0}
+
+
+ Παίρνει το χρώμα της κατάστασης της μονάδας του
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.en.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.en.resx
new file mode 100644
index 000000000..00c33c607
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.en.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Map Icon Legend
+
+
+ Every icon the map shows for calls, units and personnel. A call's icon follows its type and priority, a unit's its type and status, and a member's their role and status. Staffing levels never change an icon.
+
+
+ How an icon is picked
+
+
+ An icon uploaded for the type or role for that exact priority or status.
+
+
+ Otherwise the type's or role's own uploaded icon, with a badge in the color of the priority or status.
+
+
+ Otherwise the built-in pin in the color of the priority or status.
+
+
+ A priority or status with no meaning set is colored by what its name suggests, such as Emergency or Out of Service; one whose name suggests nothing shows a gray pin, as does a unit or member that has set no status. Set meanings on the Custom Statuses and Call Priorities pages to be sure.
+
+
+ Status colors
+
+
+ Units and personnel take the color of their status's meaning.
+
+
+ Priority colors
+
+
+ Calls take the color of their priority's meaning.
+
+
+ Available
+
+
+ Dispatched
+
+
+ Responding
+
+
+ On scene
+
+
+ Transporting
+
+
+ Returning
+
+
+ Staging and standby
+
+
+ On break
+
+
+ Unavailable
+
+
+ No meaning or no status
+
+
+ Low
+
+
+ Medium
+
+
+ High
+
+
+ Emergency
+
+
+ Calls
+
+
+ One row per call type, one column per priority.
+
+
+ Units
+
+
+ One row per unit type, with the statuses of its status set.
+
+
+ Personnel
+
+
+ A member shows the icon of the first role they hold, in role order, that has one. A member On Unit takes the color of their unit's status.
+
+
+ Stations, hydrants and points of interest
+
+
+ These icons do not change with status.
+
+
+ Calls with no type
+
+
+ Units with no type
+
+
+ Members with no role icon
+
+
+ Built-in unit statuses
+
+
+ Station
+
+
+ Hydrant
+
+
+ Edit icons
+
+
+ Key
+
+
+ Uploaded for this status or priority
+
+
+ Type or role icon with a status or priority badge
+
+
+ Built-in pin
+
+
+ No meaning: gray pin
+
+
+ Type
+
+
+ Role
+
+
+ Status set
+
+
+ Map icons for {0}
+
+
+ Upload an icon for a status to show it on the map while a unit of this type is in that status. Without one, the default shown is used.
+
+
+ Upload an icon for a priority to show it on the map while a call of this type has that priority. Without one, the default shown is used.
+
+
+ Upload an icon for a status to show it on the map while a member whose icon comes from this role is in that status. Without one, the default shown is used.
+
+
+ Icon with no status or priority
+
+
+ Status
+
+
+ Priority
+
+
+ Map color
+
+
+ Default
+
+
+ Uploaded icon
+
+
+ Upload
+
+
+ Remove
+
+
+ None
+
+
+ Icon saved.
+
+
+ Icon removed.
+
+
+ Status map icons
+
+
+ Priority map icons
+
+
+ A different map icon for each status, so a unit or member is easy to read at a glance.
+
+
+ A different map icon for each priority, so a call is easy to read at a glance.
+
+
+ Your unit statuses: {0}
+
+
+ Your personnel statuses: {0}
+
+
+ Your priorities: {0}
+
+
+ Takes the color of their unit's status
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.es.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.es.resx
new file mode 100644
index 000000000..659498be2
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.es.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Leyenda de iconos del mapa
+
+
+ Todos los iconos que el mapa muestra para llamadas, unidades y personal. El icono de una llamada depende de su tipo y prioridad, el de una unidad de su tipo y estado, y el de un miembro de su rol y estado. Los niveles de dotación nunca cambian un icono.
+
+
+ Cómo se elige un icono
+
+
+ Un icono subido para el tipo o el rol para esa prioridad o estado exactos.
+
+
+ Si no, el icono propio subido del tipo o del rol, con una insignia del color de la prioridad o del estado.
+
+
+ Si no, el marcador integrado del color de la prioridad o del estado.
+
+
+ Una prioridad o un estado sin significado definido se colorea según lo que sugiere su nombre, como Emergencia o Fuera de servicio; si el nombre no sugiere nada, se muestra un marcador gris, igual que para una unidad o un miembro sin estado. Defina los significados en las páginas Estados personalizados y Prioridades de llamada para asegurarse.
+
+
+ Colores de estado
+
+
+ Las unidades y el personal toman el color del significado de su estado.
+
+
+ Colores de prioridad
+
+
+ Las llamadas toman el color del significado de su prioridad.
+
+
+ Disponible
+
+
+ Despachado
+
+
+ En respuesta
+
+
+ En el lugar
+
+
+ Trasladando
+
+
+ Regresando
+
+
+ Concentración y espera
+
+
+ En descanso
+
+
+ No disponible
+
+
+ Sin significado o sin estado
+
+
+ Baja
+
+
+ Media
+
+
+ Alta
+
+
+ Emergencia
+
+
+ Llamadas
+
+
+ Una fila por tipo de llamada y una columna por prioridad.
+
+
+ Unidades
+
+
+ Una fila por tipo de unidad, con los estados de su conjunto de estados.
+
+
+ Personal
+
+
+ Un miembro muestra el icono del primer rol que tiene, en el orden de roles, que tenga uno. Un miembro «En unidad» toma el color del estado de su unidad.
+
+
+ Estaciones, hidrantes y puntos de interés
+
+
+ Estos iconos no cambian con el estado.
+
+
+ Llamadas sin tipo
+
+
+ Unidades sin tipo
+
+
+ Miembros sin icono de rol
+
+
+ Estados de unidad integrados
+
+
+ Estación
+
+
+ Hidrante
+
+
+ Editar iconos
+
+
+ Clave
+
+
+ Subido para este estado o prioridad
+
+
+ Icono de tipo o rol con insignia de estado o prioridad
+
+
+ Marcador integrado
+
+
+ Sin significado: marcador gris
+
+
+ Tipo
+
+
+ Rol
+
+
+ Conjunto de estados
+
+
+ Iconos del mapa para {0}
+
+
+ Suba un icono para un estado para mostrarlo en el mapa mientras una unidad de este tipo esté en ese estado. Sin él, se usa el predeterminado que se muestra.
+
+
+ Suba un icono para una prioridad para mostrarlo en el mapa mientras una llamada de este tipo tenga esa prioridad. Sin él, se usa el predeterminado que se muestra.
+
+
+ Suba un icono para un estado para mostrarlo en el mapa mientras un miembro cuyo icono viene de este rol esté en ese estado. Sin él, se usa el predeterminado que se muestra.
+
+
+ Icono sin estado ni prioridad
+
+
+ Estado
+
+
+ Prioridad
+
+
+ Color en el mapa
+
+
+ Predeterminado
+
+
+ Icono subido
+
+
+ Subir
+
+
+ Quitar
+
+
+ Ninguno
+
+
+ Icono guardado.
+
+
+ Icono quitado.
+
+
+ Iconos del mapa por estado
+
+
+ Iconos del mapa por prioridad
+
+
+ Un icono de mapa distinto para cada estado, para reconocer de un vistazo una unidad o un miembro.
+
+
+ Un icono de mapa distinto para cada prioridad, para reconocer de un vistazo una llamada.
+
+
+ Sus estados de unidad: {0}
+
+
+ Sus estados de personal: {0}
+
+
+ Sus prioridades: {0}
+
+
+ Toma el color del estado de su unidad
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.fr.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.fr.resx
new file mode 100644
index 000000000..12a439dc8
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.fr.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Légende des icônes de la carte
+
+
+ Toutes les icônes que la carte affiche pour les appels, les unités et le personnel. L'icône d'un appel dépend de son type et de sa priorité, celle d'une unité de son type et de son statut, et celle d'un membre de son rôle et de son statut. Les niveaux de disponibilité ne changent jamais une icône.
+
+
+ Comment une icône est choisie
+
+
+ Une icône téléversée pour le type ou le rôle pour cette priorité ou ce statut précis.
+
+
+ Sinon, l'icône téléversée du type ou du rôle, avec un badge de la couleur de la priorité ou du statut.
+
+
+ Sinon, le repère intégré de la couleur de la priorité ou du statut.
+
+
+ Une priorité ou un statut sans signification définie prend la couleur que suggère son nom, comme Urgence ou Hors service ; si le nom ne suggère rien, un repère gris s'affiche, comme pour une unité ou un membre sans statut. Définissez les significations sur les pages Statuts personnalisés et Priorités des appels pour en être sûr.
+
+
+ Couleurs des statuts
+
+
+ Les unités et le personnel prennent la couleur de la signification de leur statut.
+
+
+ Couleurs des priorités
+
+
+ Les appels prennent la couleur de la signification de leur priorité.
+
+
+ Disponible
+
+
+ Engagé
+
+
+ En intervention
+
+
+ Sur les lieux
+
+
+ En transport
+
+
+ En retour
+
+
+ Regroupement et attente
+
+
+ En pause
+
+
+ Indisponible
+
+
+ Sans signification ou sans statut
+
+
+ Basse
+
+
+ Moyenne
+
+
+ Haute
+
+
+ Urgence
+
+
+ Appels
+
+
+ Une ligne par type d'appel, une colonne par priorité.
+
+
+ Unités
+
+
+ Une ligne par type d'unité, avec les statuts de son ensemble de statuts.
+
+
+ Personnel
+
+
+ Un membre affiche l'icône du premier de ses rôles, dans l'ordre des rôles, qui en a une. Un membre « Sur une unité » prend la couleur du statut de son unité.
+
+
+ Casernes, bornes d'incendie et points d'intérêt
+
+
+ Ces icônes ne changent pas avec le statut.
+
+
+ Appels sans type
+
+
+ Unités sans type
+
+
+ Membres sans icône de rôle
+
+
+ Statuts d'unité intégrés
+
+
+ Caserne
+
+
+ Borne d'incendie
+
+
+ Modifier les icônes
+
+
+ Clé
+
+
+ Téléversée pour ce statut ou cette priorité
+
+
+ Icône de type ou de rôle avec un badge de statut ou de priorité
+
+
+ Repère intégré
+
+
+ Sans signification : repère gris
+
+
+ Type
+
+
+ Rôle
+
+
+ Ensemble de statuts
+
+
+ Icônes de la carte pour {0}
+
+
+ Téléversez une icône pour un statut afin de l'afficher sur la carte tant qu'une unité de ce type est dans ce statut. Sans icône, celle affichée par défaut est utilisée.
+
+
+ Téléversez une icône pour une priorité afin de l'afficher sur la carte tant qu'un appel de ce type a cette priorité. Sans icône, celle affichée par défaut est utilisée.
+
+
+ Téléversez une icône pour un statut afin de l'afficher sur la carte tant qu'un membre dont l'icône vient de ce rôle est dans ce statut. Sans icône, celle affichée par défaut est utilisée.
+
+
+ Icône sans statut ni priorité
+
+
+ Statut
+
+
+ Priorité
+
+
+ Couleur sur la carte
+
+
+ Par défaut
+
+
+ Icône téléversée
+
+
+ Téléverser
+
+
+ Supprimer
+
+
+ Aucune
+
+
+ Icône enregistrée.
+
+
+ Icône supprimée.
+
+
+ Icônes de carte par statut
+
+
+ Icônes de carte par priorité
+
+
+ Une icône de carte différente pour chaque statut, pour lire d'un coup d'œil une unité ou un membre.
+
+
+ Une icône de carte différente pour chaque priorité, pour lire d'un coup d'œil un appel.
+
+
+ Vos statuts d'unité : {0}
+
+
+ Vos statuts du personnel : {0}
+
+
+ Vos priorités : {0}
+
+
+ Prend la couleur du statut de son unité
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.it.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.it.resx
new file mode 100644
index 000000000..5475078f1
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.it.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Legenda delle icone della mappa
+
+
+ Tutte le icone che la mappa mostra per chiamate, unità e personale. L'icona di una chiamata dipende dal tipo e dalla priorità, quella di un'unità dal tipo e dallo stato e quella di un membro dal ruolo e dallo stato. I livelli di disponibilità non cambiano mai un'icona.
+
+
+ Come viene scelta un'icona
+
+
+ Un'icona caricata per il tipo o il ruolo proprio per quella priorità o quello stato.
+
+
+ Altrimenti l'icona caricata del tipo o del ruolo, con un badge del colore della priorità o dello stato.
+
+
+ Altrimenti il segnaposto integrato del colore della priorità o dello stato.
+
+
+ Una priorità o uno stato senza significato impostato prende il colore suggerito dal suo nome, come Emergenza o Fuori servizio; se il nome non suggerisce nulla, compare un segnaposto grigio, come per un'unità o un membro senza stato. Imposta i significati nelle pagine Stati personalizzati e Priorità delle chiamate per esserne certo.
+
+
+ Colori degli stati
+
+
+ Unità e personale assumono il colore del significato del loro stato.
+
+
+ Colori delle priorità
+
+
+ Le chiamate assumono il colore del significato della loro priorità.
+
+
+ Disponibile
+
+
+ Inviato
+
+
+ In risposta
+
+
+ Sul posto
+
+
+ In trasporto
+
+
+ In rientro
+
+
+ Ammassamento e attesa
+
+
+ In pausa
+
+
+ Non disponibile
+
+
+ Nessun significato o nessuno stato
+
+
+ Bassa
+
+
+ Media
+
+
+ Alta
+
+
+ Emergenza
+
+
+ Chiamate
+
+
+ Una riga per tipo di chiamata, una colonna per priorità.
+
+
+ Unità
+
+
+ Una riga per tipo di unità, con gli stati del suo insieme di stati.
+
+
+ Personale
+
+
+ Un membro mostra l'icona del primo ruolo che ricopre, nell'ordine dei ruoli, che ne ha una. Un membro «Su unità» assume il colore dello stato della sua unità.
+
+
+ Stazioni, idranti e punti di interesse
+
+
+ Queste icone non cambiano con lo stato.
+
+
+ Chiamate senza tipo
+
+
+ Unità senza tipo
+
+
+ Membri senza icona di ruolo
+
+
+ Stati unità integrati
+
+
+ Stazione
+
+
+ Idrante
+
+
+ Modifica icone
+
+
+ Chiave
+
+
+ Caricata per questo stato o questa priorità
+
+
+ Icona di tipo o ruolo con badge di stato o priorità
+
+
+ Segnaposto integrato
+
+
+ Nessun significato: segnaposto grigio
+
+
+ Tipo
+
+
+ Ruolo
+
+
+ Insieme di stati
+
+
+ Icone della mappa per {0}
+
+
+ Carica un'icona per uno stato per mostrarla sulla mappa mentre un'unità di questo tipo è in quello stato. Senza, si usa quella predefinita mostrata.
+
+
+ Carica un'icona per una priorità per mostrarla sulla mappa mentre una chiamata di questo tipo ha quella priorità. Senza, si usa quella predefinita mostrata.
+
+
+ Carica un'icona per uno stato per mostrarla sulla mappa mentre un membro la cui icona viene da questo ruolo è in quello stato. Senza, si usa quella predefinita mostrata.
+
+
+ Icona senza stato né priorità
+
+
+ Stato
+
+
+ Priorità
+
+
+ Colore sulla mappa
+
+
+ Predefinita
+
+
+ Icona caricata
+
+
+ Carica
+
+
+ Rimuovi
+
+
+ Nessuna
+
+
+ Icona salvata.
+
+
+ Icona rimossa.
+
+
+ Icone della mappa per stato
+
+
+ Icone della mappa per priorità
+
+
+ Un'icona della mappa diversa per ogni stato, per riconoscere a colpo d'occhio un'unità o un membro.
+
+
+ Un'icona della mappa diversa per ogni priorità, per riconoscere a colpo d'occhio una chiamata.
+
+
+ I tuoi stati delle unità: {0}
+
+
+ I tuoi stati del personale: {0}
+
+
+ Le tue priorità: {0}
+
+
+ Assume il colore dello stato della sua unità
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.pl.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.pl.resx
new file mode 100644
index 000000000..18758e30d
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.pl.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Legenda ikon mapy
+
+
+ Wszystkie ikony, które mapa pokazuje dla zgłoszeń, jednostek i personelu. Ikona zgłoszenia zależy od jego typu i priorytetu, ikona jednostki od jej typu i statusu, a ikona członka od jego roli i statusu. Poziomy obsady nigdy nie zmieniają ikony.
+
+
+ Jak wybierana jest ikona
+
+
+ Ikona przesłana dla typu lub roli dokładnie dla tego priorytetu lub statusu.
+
+
+ W przeciwnym razie własna przesłana ikona typu lub roli, z plakietką w kolorze priorytetu lub statusu.
+
+
+ W przeciwnym razie wbudowana pinezka w kolorze priorytetu lub statusu.
+
+
+ Priorytet lub status bez ustawionego znaczenia otrzymuje kolor sugerowany przez jego nazwę, np. Nagły lub Poza służbą; jeśli nazwa niczego nie sugeruje, pojawia się szara pinezka, tak jak dla jednostki lub członka bez statusu. Ustaw znaczenia na stronach Statusy niestandardowe i Priorytety zgłoszeń, aby mieć pewność.
+
+
+ Kolory statusów
+
+
+ Jednostki i personel przyjmują kolor znaczenia swojego statusu.
+
+
+ Kolory priorytetów
+
+
+ Zgłoszenia przyjmują kolor znaczenia swojego priorytetu.
+
+
+ Dostępny
+
+
+ Zadysponowany
+
+
+ W drodze
+
+
+ Na miejscu
+
+
+ W transporcie
+
+
+ Powrót
+
+
+ Strefa wyczekiwania i gotowość
+
+
+ Przerwa
+
+
+ Niedostępny
+
+
+ Brak znaczenia lub statusu
+
+
+ Niski
+
+
+ Średni
+
+
+ Wysoki
+
+
+ Nagły
+
+
+ Zgłoszenia
+
+
+ Jeden wiersz na typ zgłoszenia, jedna kolumna na priorytet.
+
+
+ Jednostki
+
+
+ Jeden wiersz na typ jednostki, ze statusami jej zestawu statusów.
+
+
+ Personel
+
+
+ Członek pokazuje ikonę pierwszej posiadanej roli, w kolejności ról, która ją ma. Członek „W jednostce” przyjmuje kolor statusu swojej jednostki.
+
+
+ Stacje, hydranty i punkty zainteresowania
+
+
+ Te ikony nie zmieniają się wraz ze statusem.
+
+
+ Zgłoszenia bez typu
+
+
+ Jednostki bez typu
+
+
+ Członkowie bez ikony roli
+
+
+ Wbudowane statusy jednostek
+
+
+ Stacja
+
+
+ Hydrant
+
+
+ Edytuj ikony
+
+
+ Objaśnienia
+
+
+ Przesłana dla tego statusu lub priorytetu
+
+
+ Ikona typu lub roli z plakietką statusu lub priorytetu
+
+
+ Wbudowana pinezka
+
+
+ Brak znaczenia: szara pinezka
+
+
+ Typ
+
+
+ Rola
+
+
+ Zestaw statusów
+
+
+ Ikony mapy dla {0}
+
+
+ Prześlij ikonę dla statusu, aby pokazywać ją na mapie, gdy jednostka tego typu ma ten status. Bez niej używana jest pokazana ikona domyślna.
+
+
+ Prześlij ikonę dla priorytetu, aby pokazywać ją na mapie, gdy zgłoszenie tego typu ma ten priorytet. Bez niej używana jest pokazana ikona domyślna.
+
+
+ Prześlij ikonę dla statusu, aby pokazywać ją na mapie, gdy członek, którego ikona pochodzi z tej roli, ma ten status. Bez niej używana jest pokazana ikona domyślna.
+
+
+ Ikona bez statusu ani priorytetu
+
+
+ Status
+
+
+ Priorytet
+
+
+ Kolor na mapie
+
+
+ Domyślna
+
+
+ Przesłana ikona
+
+
+ Prześlij
+
+
+ Usuń
+
+
+ Brak
+
+
+ Ikona zapisana.
+
+
+ Ikona usunięta.
+
+
+ Ikony mapy według statusu
+
+
+ Ikony mapy według priorytetu
+
+
+ Inna ikona mapy dla każdego statusu, aby jednostkę lub członka było widać na pierwszy rzut oka.
+
+
+ Inna ikona mapy dla każdego priorytetu, aby zgłoszenie było widać na pierwszy rzut oka.
+
+
+ Twoje statusy jednostek: {0}
+
+
+ Twoje statusy personelu: {0}
+
+
+ Twoje priorytety: {0}
+
+
+ Przyjmuje kolor statusu swojej jednostki
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.sv.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.sv.resx
new file mode 100644
index 000000000..ada49ddff
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.sv.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Förklaring till kartikoner
+
+
+ Alla ikoner som kartan visar för larm, enheter och personal. Ett larms ikon följer dess typ och prioritet, en enhets dess typ och status och en medlems dess roll och status. Bemanningsnivåer ändrar aldrig en ikon.
+
+
+ Så väljs en ikon
+
+
+ En ikon som laddats upp för typen eller rollen för just den prioriteten eller statusen.
+
+
+ Annars typens eller rollens egen uppladdade ikon, med en bricka i prioritetens eller statusens färg.
+
+
+ Annars den inbyggda nålen i prioritetens eller statusens färg.
+
+
+ En prioritet eller status utan angiven betydelse färgas efter vad namnet antyder, som Akut eller Ur tjänst; antyder namnet inget visas en grå nål, liksom för en enhet eller medlem utan status. Ange betydelser på sidorna Anpassade statusar och Larmprioriteter för att vara säker.
+
+
+ Statusfärger
+
+
+ Enheter och personal får färgen för betydelsen av sin status.
+
+
+ Prioritetsfärger
+
+
+ Larm får färgen för betydelsen av sin prioritet.
+
+
+ Tillgänglig
+
+
+ Larmad
+
+
+ På väg
+
+
+ På plats
+
+
+ Transporterar
+
+
+ Återvänder
+
+
+ Uppställning och beredskap
+
+
+ Rast
+
+
+ Inte tillgänglig
+
+
+ Ingen betydelse eller ingen status
+
+
+ Låg
+
+
+ Medel
+
+
+ Hög
+
+
+ Akut
+
+
+ Larm
+
+
+ En rad per larmtyp, en kolumn per prioritet.
+
+
+ Enheter
+
+
+ En rad per enhetstyp, med statusarna i dess statusuppsättning.
+
+
+ Personal
+
+
+ En medlem visar ikonen för den första roll de har, i rollordning, som har en. En medlem ”På enhet” får färgen för sin enhets status.
+
+
+ Stationer, brandposter och intressepunkter
+
+
+ Dessa ikoner ändras inte med status.
+
+
+ Larm utan typ
+
+
+ Enheter utan typ
+
+
+ Medlemmar utan rollikon
+
+
+ Inbyggda enhetsstatusar
+
+
+ Station
+
+
+ Brandpost
+
+
+ Redigera ikoner
+
+
+ Teckenförklaring
+
+
+ Uppladdad för denna status eller prioritet
+
+
+ Typ- eller rollikon med status- eller prioritetsbricka
+
+
+ Inbyggd nål
+
+
+ Ingen betydelse: grå nål
+
+
+ Typ
+
+
+ Roll
+
+
+ Statusuppsättning
+
+
+ Kartikoner för {0}
+
+
+ Ladda upp en ikon för en status för att visa den på kartan medan en enhet av denna typ har den statusen. Utan en används standardikonen som visas.
+
+
+ Ladda upp en ikon för en prioritet för att visa den på kartan medan ett larm av denna typ har den prioriteten. Utan en används standardikonen som visas.
+
+
+ Ladda upp en ikon för en status för att visa den på kartan medan en medlem vars ikon kommer från denna roll har den statusen. Utan en används standardikonen som visas.
+
+
+ Ikon utan status eller prioritet
+
+
+ Status
+
+
+ Prioritet
+
+
+ Kartfärg
+
+
+ Standard
+
+
+ Uppladdad ikon
+
+
+ Ladda upp
+
+
+ Ta bort
+
+
+ Ingen
+
+
+ Ikonen sparades.
+
+
+ Ikonen togs bort.
+
+
+ Kartikoner per status
+
+
+ Kartikoner per prioritet
+
+
+ En egen kartikon för varje status, så att en enhet eller medlem syns med en blick.
+
+
+ En egen kartikon för varje prioritet, så att ett larm syns med en blick.
+
+
+ Era enhetsstatusar: {0}
+
+
+ Era personalstatusar: {0}
+
+
+ Era prioriteter: {0}
+
+
+ Får färgen från sin enhets status
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.uk.resx b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.uk.resx
new file mode 100644
index 000000000..b078f5caf
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Mapping/MapIconLegend.uk.resx
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+ Легенда значків карти
+
+
+ Усі значки, які карта показує для викликів, підрозділів і персоналу. Значок виклику залежить від його типу та пріоритету, значок підрозділу — від типу та статусу, а значок учасника — від ролі та статусу. Рівні укомплектованості ніколи не змінюють значок.
+
+
+ Як вибирається значок
+
+
+ Значок, завантажений для типу чи ролі саме для цього пріоритету або статусу.
+
+
+ Інакше власний завантажений значок типу чи ролі з позначкою кольору пріоритету або статусу.
+
+
+ Інакше вбудована мітка кольору пріоритету або статусу.
+
+
+ Пріоритет або статус без заданого значення отримує колір, який підказує його назва, як-от «Екстрений» чи «Не в строю»; якщо назва нічого не підказує, показується сіра мітка, як і для підрозділу чи учасника без статусу. Задайте значення на сторінках «Власні статуси» та «Пріоритети викликів», щоб бути певними.
+
+
+ Кольори статусів
+
+
+ Підрозділи та персонал набувають кольору значення свого статусу.
+
+
+ Кольори пріоритетів
+
+
+ Виклики набувають кольору значення свого пріоритету.
+
+
+ Доступний
+
+
+ Направлений
+
+
+ Реагує
+
+
+ На місці
+
+
+ Транспортує
+
+
+ Повертається
+
+
+ Збір і очікування
+
+
+ Перерва
+
+
+ Недоступний
+
+
+ Без значення або без статусу
+
+
+ Низький
+
+
+ Середній
+
+
+ Високий
+
+
+ Екстрений
+
+
+ Виклики
+
+
+ Один рядок на тип виклику, один стовпець на пріоритет.
+
+
+ Підрозділи
+
+
+ Один рядок на тип підрозділу зі статусами його набору статусів.
+
+
+ Персонал
+
+
+ Учасник показує значок першої своєї ролі в порядку ролей, яка його має. Учасник «У підрозділі» набуває кольору статусу свого підрозділу.
+
+
+ Станції, гідранти та точки інтересу
+
+
+ Ці значки не змінюються зі статусом.
+
+
+ Виклики без типу
+
+
+ Підрозділи без типу
+
+
+ Учасники без значка ролі
+
+
+ Вбудовані статуси підрозділів
+
+
+ Станція
+
+
+ Гідрант
+
+
+ Редагувати значки
+
+
+ Умовні позначення
+
+
+ Завантажено для цього статусу чи пріоритету
+
+
+ Значок типу чи ролі з позначкою статусу або пріоритету
+
+
+ Вбудована мітка
+
+
+ Без значення: сіра мітка
+
+
+ Тип
+
+
+ Роль
+
+
+ Набір статусів
+
+
+ Значки карти для {0}
+
+
+ Завантажте значок для статусу, щоб показувати його на карті, поки підрозділ цього типу має цей статус. Без нього використовується показаний типовий.
+
+
+ Завантажте значок для пріоритету, щоб показувати його на карті, поки виклик цього типу має цей пріоритет. Без нього використовується показаний типовий.
+
+
+ Завантажте значок для статусу, щоб показувати його на карті, поки учасник, чий значок походить від цієї ролі, має цей статус. Без нього використовується показаний типовий.
+
+
+ Значок без статусу чи пріоритету
+
+
+ Статус
+
+
+ Пріоритет
+
+
+ Колір на карті
+
+
+ Типовий
+
+
+ Завантажений значок
+
+
+ Завантажити
+
+
+ Вилучити
+
+
+ Немає
+
+
+ Значок збережено.
+
+
+ Значок вилучено.
+
+
+ Значки карти за статусом
+
+
+ Значки карти за пріоритетом
+
+
+ Окремий значок карти для кожного статусу, щоб підрозділ чи учасника було видно з першого погляду.
+
+
+ Окремий значок карти для кожного пріоритету, щоб виклик було видно з першого погляду.
+
+
+ Ваші статуси підрозділів: {0}
+
+
+ Ваші статуси персоналу: {0}
+
+
+ Ваші пріоритети: {0}
+
+
+ Набуває кольору статусу свого підрозділу
+
+
diff --git a/Core/Resgrid.Model/MapIconLegend.cs b/Core/Resgrid.Model/MapIconLegend.cs
new file mode 100644
index 000000000..dd917ba8b
--- /dev/null
+++ b/Core/Resgrid.Model/MapIconLegend.cs
@@ -0,0 +1,257 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Every icon a department's map can show, from its own call types, priorities, unit types, status sets, roles and
+ /// uploads (), before it is turned into URLs for a host: the web legend page, the icon
+ /// settings pages and the apps' legend (v4 Mapping/GetMapIconLegend) are all built from it, so they all show what the
+ /// map shows.
+ ///
+ public sealed class MapIconLegend
+ {
+ /// The status styles, in legend order, then .
+ public List StatusStyles { get; set; } = new List();
+
+ /// The priority styles, least to most urgent, then .
+ public List PriorityStyles { get; set; } = new List();
+
+ /// Calls with no type first, then each call type by name; a state per active priority.
+ public List CallRows { get; set; } = new List();
+
+ /// Units with no type first, then each unit type by name; a state per status of the type's status set.
+ public List UnitRows { get; set; } = new List();
+
+ /// Members with no role icon first, then each role with an icon, in role order; a state per personnel status.
+ public List PersonnelRows { get; set; } = new List();
+ }
+
+ /// A status or priority style, with the meanings it groups and the department's own statuses or priorities in it.
+ public sealed class MapIconLegendStyle
+ {
+ public string Key { get; set; }
+
+ public string Color { get; set; }
+
+ /// The status meanings the style groups (status styles only; none for unknown).
+ public List Meanings { get; set; } = new List();
+
+ /// The department's unit statuses, from every status set, that show in this style.
+ public List UnitStatusNames { get; set; } = new List();
+
+ /// The department's personnel statuses that show in this style.
+ public List PersonnelStatusNames { get; set; } = new List();
+
+ /// The department's priorities that show in this style.
+ public List PriorityNames { get; set; } = new List();
+ }
+
+ /// A call type, unit type or role (or the "none" row) with its icon in each status or priority.
+ public sealed class MapIconLegendRow
+ {
+ /// The type's or role's id; null for calls with no type, units with no type or members with no role icon.
+ public int? Id { get; set; }
+
+ public string Name { get; set; }
+
+ /// The unit type's status set name (units only); null for the built-in unit statuses.
+ public string StatusSetName { get; set; }
+
+ /// The type's or role's own icon, as it shows with no status or priority.
+ public MapMarkerIcon OwnIcon { get; set; }
+
+ public List States { get; set; } = new List();
+ }
+
+ /// One status or priority of the department, with the map icon a type shows in it.
+ public sealed class MapIconLegendState
+ {
+ /// The stored status or priority value.
+ public int Value { get; set; }
+
+ /// The icon slot an upload for it goes in.
+ public string Variant { get; set; }
+
+ /// The department's name for the status or priority.
+ public string Name { get; set; }
+
+ /// The status's or priority's own colour when it is a hex colour, else null.
+ public string Color { get; set; }
+
+ /// Its style; when it has no meaning.
+ public string Style { get; set; }
+
+ ///
+ /// The built-in personnel status On Unit: a member in it takes the style of their unit's latest status on the map, so
+ /// the gray pin shown here applies only to a member On Unit with no unit status to follow.
+ ///
+ public bool FollowsUnitStatus { get; set; }
+
+ public MapMarkerIcon Icon { get; set; }
+ }
+
+ /// Builds a department's from its .
+ public static class MapIconLegendBuilder
+ {
+ /// The department's resolver.
+ /// The department's active priorities (or the built-ins).
+ /// Every custom status set of the department.
+ /// The built-in unit statuses, for unit types without a status set.
+ /// The department's personnel statuses (or the built-ins).
+ /// The department's roles; only those with an uploaded icon of any kind get a row.
+ /// The host's name for calls with no type.
+ /// The host's name for units with no type.
+ /// The host's name for members with no role icon.
+ public static MapIconLegend Build(MapIconResolver resolver, IEnumerable priorities, IEnumerable customStates,
+ List builtInUnitStatuses, IEnumerable personnelStatuses, IEnumerable roles,
+ string noCallTypeName, string noUnitTypeName, string noRoleName)
+ {
+ var priorityList = (priorities ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ var statesList = (customStates ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ var personnelList = (personnelStatuses ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ var legend = new MapIconLegend();
+
+ legend.CallRows.Add(CallRow(resolver, null, noCallTypeName, priorityList));
+ foreach (var type in resolver.CallTypes.OrderBy(x => x.Type, StringComparer.CurrentCultureIgnoreCase))
+ legend.CallRows.Add(CallRow(resolver, type, null, priorityList));
+
+ legend.UnitRows.Add(UnitRow(resolver, null, noUnitTypeName, statesList, builtInUnitStatuses));
+ foreach (var type in resolver.UnitTypes.OrderBy(x => x.Type, StringComparer.CurrentCultureIgnoreCase))
+ legend.UnitRows.Add(UnitRow(resolver, type, null, statesList, builtInUnitStatuses));
+
+ // Roles without an icon of any kind look like the "no role icon" row, so only roles with one are listed.
+ legend.PersonnelRows.Add(RoleRow(resolver, null, noRoleName, personnelList));
+ foreach (var role in TypeIcons.InRoleOrder(roles)
+ .Where(x => !string.IsNullOrWhiteSpace(x.IconKey) || resolver.HasVariantIcons(TypeIconKinds.PersonnelRole, x.PersonnelRoleId)))
+ legend.PersonnelRows.Add(RoleRow(resolver, role, null, personnelList));
+
+ legend.StatusStyles = StatusStyles(legend.UnitRows, legend.PersonnelRows.FirstOrDefault());
+ legend.PriorityStyles = PriorityStyles(legend.CallRows.FirstOrDefault());
+
+ return legend;
+ }
+
+ ///
+ /// The status styles, then unknown, each with the meanings it groups and the department's unit statuses (from
+ /// ) and personnel statuses (from ) that land in it.
+ ///
+ public static List StatusStyles(IEnumerable unitRows = null, MapIconLegendRow personnelRow = null)
+ {
+ var unitStates = (unitRows ?? Enumerable.Empty()).Where(x => x != null).SelectMany(x => x.States).ToList();
+ var personnelStates = personnelRow?.States ?? new List();
+
+ return MapIconStyles.StatusStyles.Concat(new[] { MapIconStyles.Unknown }).Select(style => new MapIconLegendStyle
+ {
+ Key = style,
+ Color = MapIconStyles.ColorOf(style),
+ Meanings = style == MapIconStyles.Unknown ? new List() : MapIconStyles.BaseTypesOf(style).ToList(),
+ UnitStatusNames = NamesIn(unitStates, style),
+ PersonnelStatusNames = NamesIn(personnelStates, style)
+ }).ToList();
+ }
+
+ /// The priority styles, then unknown, each with the department's priorities (from ) in it.
+ public static List PriorityStyles(MapIconLegendRow callRow = null)
+ {
+ var states = callRow?.States ?? new List();
+
+ return MapIconStyles.PriorityStyles.Concat(new[] { MapIconStyles.Unknown }).Select(style => new MapIconLegendStyle
+ {
+ Key = style,
+ Color = MapIconStyles.ColorOf(style),
+ PriorityNames = NamesIn(states, style)
+ }).ToList();
+ }
+
+ // On Unit takes its unit's style, so it is never listed under the style of its own pin.
+ private static List NamesIn(IEnumerable states, string style) =>
+ states.Where(x => x.Style == style && !x.FollowsUnitStatus && !string.IsNullOrWhiteSpace(x.Name)).Select(x => x.Name.Trim())
+ .Distinct(StringComparer.CurrentCultureIgnoreCase).ToList();
+
+ /// The statuses of a unit type: its own status set's active options, else the built-in unit statuses.
+ public static (string SetName, List Statuses) UnitTypeStatuses(UnitType type, IEnumerable customStates, List builtInStatuses)
+ {
+ if (type?.CustomStatesId != null)
+ {
+ var set = (customStates ?? Enumerable.Empty())
+ .FirstOrDefault(x => x != null && x.CustomStateId == type.CustomStatesId.Value && !x.IsDeleted && x.Type == (int)CustomStateTypes.Unit);
+
+ if (set != null)
+ return (set.Name, set.GetActiveDetails().ToList());
+ }
+
+ return (null, builtInStatuses ?? new List());
+ }
+
+ public static MapIconLegendRow CallRow(MapIconResolver resolver, CallType type, string noTypeName, IEnumerable priorities) =>
+ new MapIconLegendRow
+ {
+ Id = type?.CallTypeId,
+ Name = type?.Type ?? noTypeName,
+ OwnIcon = resolver.ForCallType(type, null),
+ States = (priorities ?? Enumerable.Empty()).Select(p => PriorityState(p, resolver.ForCallType(type, p.DepartmentCallPriorityId))).ToList()
+ };
+
+ public static MapIconLegendRow UnitRow(MapIconResolver resolver, UnitType type, string noTypeName, IEnumerable customStates, List builtInStatuses)
+ {
+ var statuses = UnitTypeStatuses(type, customStates, builtInStatuses);
+
+ return new MapIconLegendRow
+ {
+ Id = type?.UnitTypeId,
+ Name = type?.Type ?? noTypeName,
+ StatusSetName = statuses.SetName,
+ OwnIcon = resolver.ForUnitType(type, null),
+ States = statuses.Statuses.Select(s => StatusState(s, resolver.ForUnitType(type, s.CustomStateDetailId))).ToList()
+ };
+ }
+
+ public static MapIconLegendRow RoleRow(MapIconResolver resolver, PersonnelRole role, string noRoleName, IEnumerable personnelStatuses) =>
+ new MapIconLegendRow
+ {
+ Id = role?.PersonnelRoleId,
+ Name = role?.Name ?? noRoleName,
+ OwnIcon = resolver.ForRole(role, null),
+ States = (personnelStatuses ?? Enumerable.Empty()).Select(s => PersonnelState(s, resolver.ForRole(role, s.CustomStateDetailId))).ToList()
+ };
+
+ private static MapIconLegendState PersonnelState(CustomStateDetail status, MapMarkerIcon icon)
+ {
+ var state = StatusState(status, icon);
+ state.FollowsUnitStatus = icon.Variant == TypeIcons.BuiltInStatusVariant((int)ActionTypes.OnUnit);
+ return state;
+ }
+
+ private static MapIconLegendState PriorityState(DepartmentCallPriority priority, MapMarkerIcon icon) =>
+ new MapIconLegendState
+ {
+ Value = priority.DepartmentCallPriorityId,
+ Variant = icon.Variant,
+ Name = priority.Name,
+ Color = HexOrNull(priority.Color),
+ Style = icon.Style,
+ Icon = icon
+ };
+
+ private static MapIconLegendState StatusState(CustomStateDetail status, MapMarkerIcon icon) =>
+ new MapIconLegendState
+ {
+ Value = status.CustomStateDetailId,
+ Variant = icon.Variant,
+ Name = status.ButtonText,
+ Color = HexOrNull(status.ButtonColor),
+ Style = icon.Style,
+ Icon = icon
+ };
+
+ /// The colour when it is a plain hex colour (safe in a style attribute or a client's colour parser), else null.
+ public static string HexOrNull(string color) =>
+ !string.IsNullOrWhiteSpace(color) && HexColor.IsMatch(color.Trim()) ? color.Trim() : null;
+
+ private static readonly Regex HexColor =
+ new Regex("^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", RegexOptions.CultureInvariant);
+ }
+}
diff --git a/Core/Resgrid.Model/MapIconResolver.cs b/Core/Resgrid.Model/MapIconResolver.cs
new file mode 100644
index 000000000..1b112a7d4
--- /dev/null
+++ b/Core/Resgrid.Model/MapIconResolver.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ /// The icon a call, unit or person shows on the map, before it is turned into URLs for a host.
+ public sealed class MapMarkerIcon
+ {
+ /// The bundled icon name clients that predate styled pins show ("fire", "Engine_Responding").
+ public string ImagePath { get; set; }
+
+ /// The built-in pin artwork the styled pin is drawn from ("fire", "engine", "person").
+ public string PinBase { get; set; }
+
+ ///
+ /// The style of the status or priority (): when it has
+ /// no meaning or the unit or member has set no status; null only for a call type, unit type or role shown without
+ /// any priority or status (the legend's and icon pages' "own icon").
+ ///
+ public string Style { get; set; }
+
+ /// The icon slot of the status or priority ( etc.), or null when there is none.
+ public string Variant { get; set; }
+
+ /// The department's uploaded icon to show, or null for a built-in pin.
+ public string IconKey { get; set; }
+
+ ///
+ /// True when is the type's icon for this very status or priority; false when it is the type's
+ /// own icon standing in for it.
+ ///
+ public bool IconIsVariant { get; set; }
+
+ /// The styled built-in pin, or null when there is no style (the plain pin named by applies).
+ public string PinName => Style == null || PinBase == null ? null : TypeIcons.PinName(PinBase, Style);
+
+ ///
+ /// The badge an uploaded type icon carries: the style, when the icon is the type's own one and the style is a real
+ /// priority or status; otherwise null (an unknown status leaves the upload as it is).
+ ///
+ public string BadgeStyle => IconKey != null && !IconIsVariant && Style != MapIconStyles.Unknown ? Style : null;
+ }
+
+ ///
+ /// Picks the map icon of a call, unit or member from what it is and what it is doing, the same way for the map, the map
+ /// icon legend and the icon settings pages:
+ ///
+ /// - the department's uploaded icon of the call type, unit type or role for the exact priority or status;
+ /// - else the type's or role's own uploaded icon, with a badge in the style of the priority or status;
+ /// - else the built-in pin the type picked (or the default call, unit or person pin), recoloured to the style;
+ /// - else, when the priority or status has no meaning, the plain built-in pin.
+ ///
+ /// A status's style comes from its meaning (, built-in statuses through
+ /// ), a priority's from its meaning (). A department's own
+ /// status or priority with no meaning set takes the meaning its name suggests (, the
+ /// suggestions the settings pages offer), for the icon only: departments that set up priorities before meanings existed
+ /// still get coloured calls, and nothing else reads this guess. Staffing levels never change an icon.
+ ///
+ public sealed class MapIconResolver
+ {
+ private readonly int _departmentId;
+ private readonly List _callTypes;
+ private readonly List _priorities;
+ private readonly List _unitTypes;
+ private readonly List _customStates;
+ private readonly Dictionary _rolesById;
+ private readonly Dictionary _variantIconKeys;
+ private readonly HashSet _typesWithVariants;
+ private readonly Dictionary _unitStatesById;
+ private readonly CustomStateDetail _defaultPersonnelStatus;
+
+ /// The department the lists belong to.
+ /// The department's call types.
+ /// The department's priorities (deleted included, so old calls resolve), or the built-ins.
+ /// The department's unit types.
+ /// Every custom status set of the department, deleted options included.
+ /// The department's personnel roles.
+ /// The department's uploaded status and priority icons.
+ /// The status a member with no status shows (GetDefaultPersonnelStatusAsync).
+ /// Each unit's latest status, so a member On Unit takes the style of the unit they ride.
+ public MapIconResolver(int departmentId, IEnumerable callTypes, IEnumerable priorities,
+ IEnumerable unitTypes, IEnumerable customStates, IEnumerable roles,
+ IEnumerable variants, CustomStateDetail defaultPersonnelStatus = null, IEnumerable latestUnitStates = null)
+ {
+ _departmentId = departmentId;
+ _callTypes = (callTypes ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ _priorities = (priorities ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ _unitTypes = (unitTypes ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ _customStates = (customStates ?? Enumerable.Empty()).Where(x => x != null).ToList();
+ _rolesById = (roles ?? Enumerable.Empty()).Where(x => x != null)
+ .GroupBy(x => x.PersonnelRoleId).ToDictionary(g => g.Key, g => g.First());
+ _variantIconKeys = new Dictionary(StringComparer.Ordinal);
+ _typesWithVariants = new HashSet(StringComparer.Ordinal);
+ foreach (var variant in (variants ?? Enumerable.Empty()).Where(x => x != null && !string.IsNullOrEmpty(x.IconKey) && !string.IsNullOrEmpty(x.Variant)))
+ {
+ _variantIconKeys[VariantLookupKey((TypeIconKinds)variant.TypeKind, variant.TypeId, variant.Variant)] = variant.IconKey;
+ _typesWithVariants.Add(TypeLookupKey((TypeIconKinds)variant.TypeKind, variant.TypeId));
+ }
+ _unitStatesById = (latestUnitStates ?? Enumerable.Empty()).Where(x => x != null && x.UnitStateId > 0)
+ .GroupBy(x => x.UnitStateId).ToDictionary(g => g.Key, g => g.First());
+ _defaultPersonnelStatus = defaultPersonnelStatus;
+ }
+
+ /// The department's call types, for the legend.
+ public IReadOnlyList CallTypes => _callTypes;
+
+ /// The department's unit types, for the legend.
+ public IReadOnlyList UnitTypes => _unitTypes;
+
+ /// The icon of a call: its type (linked by id, else by name) and its priority.
+ public MapMarkerIcon ForCall(Call call)
+ {
+ if (call == null)
+ return ForCallType(null, null);
+
+ var type = string.IsNullOrWhiteSpace(call.Type) && !call.CallTypeId.HasValue ? null : CallTypeLink.Find(call, _callTypes);
+ return ForCallType(type, call.Priority);
+ }
+
+ /// The icon of a call of (null: a call with no type) at (null: none).
+ public MapMarkerIcon ForCallType(CallType type, int? priority)
+ {
+ var builtIn = TypeIcons.BuiltInIconName(type);
+ var icon = new MapMarkerIcon
+ {
+ ImagePath = builtIn ?? TypeIcons.LegacyCallImage,
+ PinBase = builtIn ?? TypeIcons.CallPinBase
+ };
+
+ if (priority.HasValue)
+ {
+ icon.Variant = TypeIcons.PriorityVariant(priority.Value);
+ var meaning = PriorityMeaning(priority.Value);
+ icon.Style = (meaning.HasValue ? MapIconStyles.ForPriorityBaseType(meaning.Value) : null) ?? MapIconStyles.Unknown;
+ }
+
+ if (type != null)
+ PickUploaded(icon, TypeIconKinds.CallType, type.CallTypeId, type.IconKey);
+
+ return icon;
+ }
+
+ /// The icon of a unit: its type (matched by name) and its latest status; a unit that never set one is unknown.
+ public MapMarkerIcon ForUnit(Unit unit, UnitState state)
+ {
+ var type = unit == null || string.IsNullOrWhiteSpace(unit.Type) ? null : _unitTypes.FirstOrDefault(x => x.Type == unit.Type);
+ var icon = ForUnitType(type, state?.State);
+ icon.Style ??= MapIconStyles.Unknown;
+ return icon;
+ }
+
+ /// The icon of a unit of (null: no type) in status (null: none).
+ public MapMarkerIcon ForUnitType(UnitType type, int? state)
+ {
+ var builtIn = TypeIcons.BuiltInIconName(type);
+ var icon = new MapMarkerIcon
+ {
+ ImagePath = builtIn ?? TypeIcons.LegacyUnitImage,
+ PinBase = builtIn ?? TypeIcons.UnitPinBase
+ };
+
+ if (state.HasValue)
+ {
+ var status = UnitStatus(state.Value);
+ icon.Variant = status.Variant;
+ icon.Style = StatusStyle(status.Meaning);
+ }
+
+ if (type != null)
+ PickUploaded(icon, TypeIconKinds.UnitType, type.UnitTypeId, type.IconKey);
+
+ return icon;
+ }
+
+ ///
+ /// The icon of a member holding with latest status (null: they have
+ /// set none, so the department's default status applies). The icon is that of the first role they hold, in the
+ /// department's role order, with an uploaded icon for the status or an icon of its own.
+ ///
+ public MapMarkerIcon ForPerson(IEnumerable roleIds, ActionLog state)
+ {
+ var status = state != null ? PersonnelStatus(state) : DefaultPersonnelStatus();
+ var icon = new MapMarkerIcon
+ {
+ ImagePath = TypeIcons.LegacyPersonImage,
+ PinBase = TypeIcons.PersonPinBase,
+ Variant = status.Variant,
+ Style = StatusStyle(status.Meaning)
+ };
+
+ var heldRoles = (roleIds ?? Enumerable.Empty()).Where(_rolesById.ContainsKey).Select(id => _rolesById[id]);
+ foreach (var role in TypeIcons.InRoleOrder(heldRoles))
+ {
+ if (PickUploaded(icon, TypeIconKinds.PersonnelRole, role.PersonnelRoleId, role.IconKey))
+ break;
+ }
+
+ return icon;
+ }
+
+ ///
+ /// The icon a member shows when is the role their icon comes from (null: a member with no role
+ /// icon), in personnel status (null: none), for the legend and the role's icon page.
+ ///
+ public MapMarkerIcon ForRole(PersonnelRole role, int? status)
+ {
+ var resolved = status.HasValue ? PersonnelStatus(new ActionLog { ActionTypeId = status.Value }) : (Variant: null, Meaning: (int?)null);
+ var icon = new MapMarkerIcon
+ {
+ ImagePath = TypeIcons.LegacyPersonImage,
+ PinBase = TypeIcons.PersonPinBase,
+ Variant = resolved.Variant,
+ Style = status.HasValue ? StatusStyle(resolved.Meaning) : null
+ };
+
+ if (role != null)
+ PickUploaded(icon, TypeIconKinds.PersonnelRole, role.PersonnelRoleId, role.IconKey);
+
+ return icon;
+ }
+
+ ///
+ /// The meaning of a stored call priority for its icon: the department's own priority of that id first (the oldest
+ /// departments own ids 0 to 3), else the built-in priority of that value. An own priority with no meaning set takes the
+ /// one its name suggests; null when there is none.
+ ///
+ public int? PriorityMeaning(int priority)
+ {
+ var own = _priorities.FirstOrDefault(x => x.DepartmentId == _departmentId && _departmentId > 0 && x.DepartmentCallPriorityId == priority);
+ if (own != null)
+ return own.BaseType >= 0 ? own.BaseType : BaseTypeSuggestions.SuggestPriority(own.Name);
+
+ return Enum.IsDefined(typeof(CallPriority), priority) ? priority : (int?)null;
+ }
+
+ /// The icon slot and meaning of a stored unit status: one of the department's options, else a built-in one.
+ public (string Variant, int? Meaning) UnitStatus(int state)
+ {
+ var custom = StatusOptionIds.FindCustom(_customStates, CustomStateTypes.Unit, state);
+ if (custom != null)
+ return (TypeIcons.CustomStatusVariant(custom.CustomStateDetailId), OptionMeaning(custom, CustomStateTypes.Unit));
+
+ if (state < 0 || state > CallStatusLinkage.MaxBuiltInStatusId)
+ return (null, null);
+
+ return (TypeIcons.BuiltInStatusVariant(state), (int)BuiltInStatusBaseTypes.ForUnitState(state));
+ }
+
+ ///
+ /// The icon slot and meaning of a member's status: one of the department's options, else a built-in one; a built-in
+ /// On Unit linked to its unit's latest status takes that unit status's meaning.
+ ///
+ public (string Variant, int? Meaning) PersonnelStatus(ActionLog state)
+ {
+ var custom = StatusOptionIds.FindCustom(_customStates, CustomStateTypes.Personnel, state.ActionTypeId);
+ if (custom != null)
+ return (TypeIcons.CustomStatusVariant(custom.CustomStateDetailId), OptionMeaning(custom, CustomStateTypes.Personnel));
+
+ if (state.ActionTypeId < 0 || state.ActionTypeId > CallStatusLinkage.MaxBuiltInStatusId)
+ return (null, null);
+
+ var variant = TypeIcons.BuiltInStatusVariant(state.ActionTypeId);
+
+ if (PersonnelOnUnit.IsLinkedOnUnit(state, false) && _unitStatesById.TryGetValue(state.UnitStateId.Value, out var unitState))
+ return (variant, UnitStatus(unitState.State).Meaning);
+
+ return (variant, (int)BuiltInStatusBaseTypes.ForPersonnelStatus(state.ActionTypeId));
+ }
+
+ private (string Variant, int? Meaning) DefaultPersonnelStatus()
+ {
+ if (_defaultPersonnelStatus == null)
+ return (null, null);
+
+ if (_defaultPersonnelStatus.CustomStateDetailId > 0 &&
+ StatusOptionIds.FindCustom(_customStates, CustomStateTypes.Personnel, _defaultPersonnelStatus.CustomStateDetailId) != null)
+ return (TypeIcons.CustomStatusVariant(_defaultPersonnelStatus.CustomStateDetailId), OptionMeaning(_defaultPersonnelStatus, CustomStateTypes.Personnel));
+
+ return (TypeIcons.BuiltInStatusVariant((int)ActionTypes.StandingBy), (int)BuiltInStatusBaseTypes.ForPersonnelStatus((int)ActionTypes.StandingBy));
+ }
+
+ private static string StatusStyle(int? meaning) =>
+ (meaning.HasValue ? MapIconStyles.ForStatusBaseType(meaning.Value) : null) ?? MapIconStyles.Unknown;
+
+ /// A department option's meaning for its icon: the one set on it, else the one its name suggests (null for none).
+ private static int? OptionMeaning(CustomStateDetail option, CustomStateTypes type) =>
+ option.BaseType >= 0 ? option.BaseType : BaseTypeSuggestions.SuggestStatus(option.ButtonText, type);
+
+ /// True when the type or role has an uploaded icon for the icon's slot or one of its own; sets it on the icon.
+ private bool PickUploaded(MapMarkerIcon icon, TypeIconKinds kind, int typeId, string ownIconKey)
+ {
+ if (icon.Variant != null && _variantIconKeys.TryGetValue(VariantLookupKey(kind, typeId, icon.Variant), out var variantKey))
+ {
+ icon.IconKey = variantKey;
+ icon.IconIsVariant = true;
+ return true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(ownIconKey))
+ {
+ icon.IconKey = ownIconKey;
+ icon.IconIsVariant = false;
+ return true;
+ }
+
+ return false;
+ }
+
+ /// The uploaded icon of a type's slot, or null; for the icon settings pages.
+ public string VariantIconKey(TypeIconKinds kind, int typeId, string variant) =>
+ variant != null && _variantIconKeys.TryGetValue(VariantLookupKey(kind, typeId, variant), out var key) ? key : null;
+
+ /// True when the type or role has an uploaded icon for at least one status or priority.
+ public bool HasVariantIcons(TypeIconKinds kind, int typeId) => _typesWithVariants.Contains(TypeLookupKey(kind, typeId));
+
+ private static string VariantLookupKey(TypeIconKinds kind, int typeId, string variant) => $"{TypeLookupKey(kind, typeId)}:{variant}";
+
+ private static string TypeLookupKey(TypeIconKinds kind, int typeId) => $"{(int)kind}:{typeId}";
+ }
+}
diff --git a/Core/Resgrid.Model/MapIconStyles.cs b/Core/Resgrid.Model/MapIconStyles.cs
new file mode 100644
index 000000000..a5e8fa827
--- /dev/null
+++ b/Core/Resgrid.Model/MapIconStyles.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ ///
+ /// The look a map marker takes for what its unit or person is doing, or how urgent its call is. A style is a coarse group
+ /// of meanings ( for statuses, for priorities), so every
+ /// department's statuses and priorities, built-in or custom, land on the same small map language whatever they are
+ /// named or coloured. The built-in pins are recoloured to the style's colour; an uploaded icon without an icon of its own
+ /// for the status or priority carries a badge of that colour. The keys are wire values (map marker IconStyle,
+ /// pin names, the s parameter of the icon endpoints): never rename one.
+ ///
+ public static class MapIconStyles
+ {
+ ///
+ /// Bumped whenever a colour or the pin artwork changes, so pin and badge URLs change with it and clients holding the
+ /// old images under an immutable cache header load the new ones.
+ ///
+ public const int Version = 1;
+
+ // Unit and personnel status styles.
+ public const string Available = "available";
+ public const string Dispatched = "dispatched";
+ public const string Responding = "responding";
+ public const string OnScene = "onscene";
+ public const string Transporting = "transporting";
+ public const string Returning = "returning";
+ public const string Staging = "staging";
+ public const string Delayed = "delayed";
+ public const string Unavailable = "unavailable";
+
+ ///
+ /// A call, unit or member whose priority or status has no meaning (and a name that suggests none), or a unit or
+ /// member that has set no status: a gray pin, so it never passes for one of the coloured styles.
+ ///
+ public const string Unknown = "unknown";
+
+ // Call priority styles.
+ public const string PriorityLow = "low";
+ public const string PriorityMedium = "medium";
+ public const string PriorityHigh = "high";
+ public const string PriorityEmergency = "emergency";
+
+ /// The status styles, in the order a legend lists them.
+ public static readonly IReadOnlyList StatusStyles = new[]
+ {
+ Available, Dispatched, Responding, OnScene, Transporting, Returning, Staging, Delayed, Unavailable
+ };
+
+ /// The priority styles, least to most urgent.
+ public static readonly IReadOnlyList PriorityStyles = new[] { PriorityLow, PriorityMedium, PriorityHigh, PriorityEmergency };
+
+ private static readonly Dictionary Colors = new Dictionary(StringComparer.Ordinal)
+ {
+ // Dark enough that the white glyph of a built-in pin stays readable on every one.
+ { Available, "#2E9E44" },
+ { Dispatched, "#B7791F" },
+ { Responding, "#D93A1E" },
+ { OnScene, "#1D5FD1" },
+ { Transporting, "#7A3EBE" },
+ { Returning, "#0C8C99" },
+ { Staging, "#8A6A3A" },
+ { Delayed, "#607D8B" },
+ { Unavailable, "#3D3D3D" },
+ { Unknown, "#8A8A8A" },
+ // The built-in priorities' own colours (green, yellow, orange, red), darkened for the same reason.
+ { PriorityLow, "#2E8B3E" },
+ { PriorityMedium, "#B59A00" },
+ { PriorityHigh, "#E07800" },
+ { PriorityEmergency, "#C8102E" }
+ };
+
+ /// True for a known style key.
+ public static bool IsStyle(string style) => style != null && Colors.ContainsKey(style);
+
+ /// True for a status style (units and personnel).
+ public static bool IsStatusStyle(string style) => style != null && StatusStyles.Contains(style);
+
+ /// True for a call priority style.
+ public static bool IsPriorityStyle(string style) => style != null && PriorityStyles.Contains(style);
+
+ /// The style's colour as "#RRGGBB", or null for an unknown style.
+ public static string ColorOf(string style) => style != null && Colors.TryGetValue(style, out var color) ? color : null;
+
+ ///
+ /// The style for a status meaning (), or null for a status without one (the resolver then
+ /// uses ).
+ ///
+ public static string ForStatusBaseType(int baseType)
+ {
+ switch ((ActionBaseTypes)baseType)
+ {
+ case ActionBaseTypes.Available:
+ case ActionBaseTypes.Cleared:
+ case ActionBaseTypes.OnPatrol:
+ case ActionBaseTypes.Completed:
+ case ActionBaseTypes.InQuarters:
+ return Available;
+ case ActionBaseTypes.Dispatched:
+ return Dispatched;
+ case ActionBaseTypes.Responding:
+ case ActionBaseTypes.Enroute:
+ return Responding;
+ case ActionBaseTypes.OnScene:
+ case ActionBaseTypes.MadeContact:
+ case ActionBaseTypes.Investigating:
+ case ActionBaseTypes.AtPatient:
+ case ActionBaseTypes.Searching:
+ return OnScene;
+ case ActionBaseTypes.Transporting:
+ case ActionBaseTypes.Delivering:
+ case ActionBaseTypes.Loading:
+ case ActionBaseTypes.AtHospital:
+ return Transporting;
+ case ActionBaseTypes.Returning:
+ return Returning;
+ case ActionBaseTypes.Staging:
+ case ActionBaseTypes.Standby:
+ return Staging;
+ case ActionBaseTypes.OnBreak:
+ return Delayed;
+ case ActionBaseTypes.NotResponding:
+ case ActionBaseTypes.Unavailable:
+ case ActionBaseTypes.Maintenance:
+ return Unavailable;
+ default:
+ return null;
+ }
+ }
+
+ /// The status meanings a status style groups, for the legend.
+ public static IEnumerable BaseTypesOf(string style) =>
+ Enum.GetValues(typeof(ActionBaseTypes)).Cast().Where(x => ForStatusBaseType((int)x) == style);
+
+ /// The style for a priority meaning (a value), or null for none.
+ public static string ForPriorityBaseType(int baseType)
+ {
+ switch ((CallPriority)baseType)
+ {
+ case CallPriority.Low:
+ return PriorityLow;
+ case CallPriority.Medium:
+ return PriorityMedium;
+ case CallPriority.High:
+ return PriorityHigh;
+ case CallPriority.Emergency:
+ return PriorityEmergency;
+ default:
+ return null;
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/ITypeIconsRepository.cs b/Core/Resgrid.Model/Repositories/ITypeIconsRepository.cs
index 89786a22e..375e7bde7 100644
--- a/Core/Resgrid.Model/Repositories/ITypeIconsRepository.cs
+++ b/Core/Resgrid.Model/Repositories/ITypeIconsRepository.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -8,13 +9,42 @@ public interface ITypeIconsRepository : IRepository
{
Task GetByIconKeyAsync(string iconKey);
- /// Deletes the type's icon rows, except the one under when given.
+ ///
+ /// Deletes the type's icon rows, its status and priority icons included, except the one under
+ /// when given. The type deletes use it.
+ ///
Task DeleteForTypeAsync(int departmentId, TypeIconKinds kind, int typeId, string keepIconKey = null, CancellationToken cancellationToken = default);
+ ///
+ /// Deletes the rows of one icon slot of the type: its own icon when is null, else the icon
+ /// of that status or priority; except the one under when given.
+ ///
+ Task DeleteSlotAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, string keepIconKey = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes every row of one icon slot (as ) but its newest upload (latest UploadedOn, then
+ /// the greater IconKey), in one statement: of two uploads to the slot at once (a double-submitted form), whichever runs
+ /// it last sees both rows, so the slot keeps one icon and never ends up with none.
+ ///
+ Task DeleteAllButNewestInSlotAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, CancellationToken cancellationToken = default);
+
+ /// Every status and priority icon of the department, without bytes.
+ Task> GetVariantsForDepartmentAsync(int departmentId);
+
+ /// True when the unit type, call type, POI type or role is the department's.
+ Task TypeBelongsToDepartmentAsync(int departmentId, TypeIconKinds kind, int typeId);
+
///
/// Points the department's unit, call or POI type at (null clears it) with a targeted
/// update, so no other column of the type is rewritten. Returns the rows changed: 0 when the type is not the department's.
///
Task SetTypeIconKeyAsync(int departmentId, TypeIconKinds kind, int typeId, string iconKey, CancellationToken cancellationToken = default);
+
+ ///
+ /// Points the department's type at the newest upload of its own icon slot (as
+ /// picks it; null when the slot is empty), in one statement, so of two uploads at once the one that runs it last leaves
+ /// the type on the icon the slot keeps. Returns the rows changed: 0 when the type is not the department's.
+ ///
+ Task SetTypeIconKeyToNewestAsync(int departmentId, TypeIconKinds kind, int typeId, CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/Services/ICustomStateService.cs b/Core/Resgrid.Model/Services/ICustomStateService.cs
index 1ce2c7ed6..dcb0b3226 100644
--- a/Core/Resgrid.Model/Services/ICustomStateService.cs
+++ b/Core/Resgrid.Model/Services/ICustomStateService.cs
@@ -47,7 +47,10 @@ public interface ICustomStateService
///
/// The status a daily status reset sets everyone to. is the reset's stored value: "b{n}" is a
/// meaning, resolved to the department's status with it; a plain value saved before meanings is kept while it is still
- /// one of the department's statuses, otherwise it follows its meaning. Null when nothing matches (nobody is reset).
+ /// one of the department's statuses, otherwise it follows its meaning; when no status has that meaning, a status
+ /// without a meaning matched by name () stands in, and when none does it is kept as the
+ /// reset wrote it before meanings. Null when nothing matches (nobody is reset): a meaning the department has no
+ /// status for, or a plain value that is neither built-in nor one of the department's options.
///
Task ResolveStatusResetAsync(int departmentId, string target);
/// The staffing level a daily staffing reset sets everyone to; the same rules as .
diff --git a/Core/Resgrid.Model/Services/IMapIconsService.cs b/Core/Resgrid.Model/Services/IMapIconsService.cs
new file mode 100644
index 000000000..31c5b1df1
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IMapIconsService.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ /// Builds the department's : the map icon of every call, unit and member.
+ public interface IMapIconsService
+ {
+ ///
+ /// The department's resolver, from its call types, priorities, unit types, status sets, roles and uploaded status and
+ /// priority icons. Pass when members On Unit should take the style of the unit
+ /// they ride (the map already holds them).
+ ///
+ Task GetResolverAsync(int departmentId, IEnumerable latestUnitStates = null);
+
+ ///
+ /// Every icon the department's map can show (), named with the department's own
+ /// call types, priorities, unit types, status sets, statuses and roles. The "none" rows take the host's names.
+ ///
+ Task GetLegendAsync(int departmentId, string noCallTypeName, string noUnitTypeName, string noRoleName);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/ITypeIconsService.cs b/Core/Resgrid.Model/Services/ITypeIconsService.cs
index 758270f81..d5d8e8f87 100644
--- a/Core/Resgrid.Model/Services/ITypeIconsService.cs
+++ b/Core/Resgrid.Model/Services/ITypeIconsService.cs
@@ -1,9 +1,13 @@
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Resgrid.Model.Services
{
- /// Department-uploaded unit, call and POI type icons (M0285) and personnel role icons (M0286).
+ ///
+ /// Department-uploaded unit, call and POI type icons (M0285), personnel role icons (M0286) and the status and priority
+ /// icons of unit types, call types and roles (M0289).
+ ///
public interface ITypeIconsService
{
/// Why cannot be a type icon; when it can.
@@ -11,14 +15,31 @@ public interface ITypeIconsService
///
/// Re-encodes to the stored icon and makes it the type's icon under a new IconKey, replacing
- /// any earlier upload. Throws a user-facing rejection when the file is not a usable image. Returns the new IconKey,
- /// or null when the type is not this department's.
+ /// any earlier upload (of uploads at once, the newest is kept). Throws a user-facing rejection when the file is not a
+ /// usable image. Returns the new IconKey, or null when the type is not this department's.
///
Task UploadIconAsync(int departmentId, TypeIconKinds kind, int typeId, string userId, byte[] data, CancellationToken cancellationToken = default);
- /// Removes the uploaded icon; the type falls back to its built-in icon. False when the type is not this department's.
+ ///
+ /// Removes the uploaded icon; the type falls back to its built-in icon. Its status and priority icons stay. False when
+ /// the type is not this department's.
+ ///
Task RemoveIconAsync(int departmentId, TypeIconKinds kind, int typeId, CancellationToken cancellationToken = default);
+ ///
+ /// Makes the icon the type shows on the map while its call has the priority, or its unit or
+ /// member the status, named by (), replacing any
+ /// earlier upload for it. Throws a user-facing rejection when the file is not a usable image. Returns the new IconKey,
+ /// or null when the type is not this department's or cannot have that slot.
+ ///
+ Task UploadVariantIconAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, string userId, byte[] data, CancellationToken cancellationToken = default);
+
+ /// Removes the type's icon for one status or priority. False when the type is not this department's or cannot have that slot.
+ Task RemoveVariantIconAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, CancellationToken cancellationToken = default);
+
+ /// Every status and priority icon of the department (no bytes), cached until one is uploaded or removed.
+ Task> GetVariantsForDepartmentAsync(int departmentId, bool bypassCache = false);
+
/// The stored icon for an IconKey, or null for a malformed or unknown key.
Task GetIconByKeyAsync(string iconKey);
}
diff --git a/Core/Resgrid.Model/StatusNameMatch.cs b/Core/Resgrid.Model/StatusNameMatch.cs
new file mode 100644
index 000000000..14aa8144a
--- /dev/null
+++ b/Core/Resgrid.Model/StatusNameMatch.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Name matching for a status or staffing level saved before meanings existed (a built-in value, or one of the
+ /// department's options since deleted) when no option of the department's set has its meaning. Sets made before meanings
+ /// mostly have none on any option (the column defaulted to None), so a daily reset saved as "Standing By" or "Unavailable"
+ /// finds nothing by meaning. Only options without a meaning are matched by name, so a meaning an administrator chose is
+ /// never second-guessed.
+ ///
+ public static class StatusNameMatch
+ {
+ ///
+ /// The first option (in the order given) without a meaning whose name is , ignoring case,
+ /// spaces and punctuation; else the first whose name suggests the saved value's meaning (, or
+ /// the one suggests when it has none). Null when no option does.
+ ///
+ /// The department set's active options, in order.
+ /// The kind of set (personnel status, staffing or unit status).
+ /// The saved value's name: the built-in status's or the deleted option's text.
+ /// The saved value's meaning, or null (or None) when it has none.
+ public static CustomStateDetail Find(IEnumerable activeOptions, CustomStateTypes type, string savedName, int? meaning)
+ {
+ var candidates = (activeOptions ?? Enumerable.Empty()).Where(x => x != null && x.BaseType < 0).ToList();
+ if (candidates.Count == 0)
+ return null;
+
+ var name = BaseTypeSuggestions.Normalize(savedName);
+ if (name.Length > 0)
+ {
+ var sameName = candidates.FirstOrDefault(x => BaseTypeSuggestions.Normalize(x.ButtonText) == name);
+ if (sameName != null)
+ return sameName;
+ }
+
+ var savedMeaning = meaning.HasValue && meaning.Value >= 0 ? meaning : BaseTypeSuggestions.SuggestStatus(savedName, type);
+ if (!savedMeaning.HasValue || savedMeaning.Value < 0)
+ return null;
+
+ return candidates.FirstOrDefault(x => BaseTypeSuggestions.SuggestStatus(x.ButtonText, type) == savedMeaning.Value);
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/TypeIcon.cs b/Core/Resgrid.Model/TypeIcon.cs
index 1044949d4..865bdd48c 100644
--- a/Core/Resgrid.Model/TypeIcon.cs
+++ b/Core/Resgrid.Model/TypeIcon.cs
@@ -3,6 +3,8 @@
using System.Linq;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.RegularExpressions;
+using ProtoBuf;
namespace Resgrid.Model
{
@@ -34,10 +36,32 @@ public enum TypeIconRejections
TooSmall = 6
}
+ ///
+ /// The key, kind and slot of one uploaded status or priority icon, without its bytes: what the map needs to pick an
+ /// icon. Cached per department.
+ ///
+ [ProtoContract]
+ public class TypeIconVariant
+ {
+ [ProtoMember(1)]
+ public int TypeKind { get; set; }
+
+ [ProtoMember(2)]
+ public int TypeId { get; set; }
+
+ [ProtoMember(3)]
+ public string Variant { get; set; }
+
+ [ProtoMember(4)]
+ public string IconKey { get; set; }
+ }
+
///
/// A department's uploaded icon for one unit, call or POI type (migration M0285) or personnel role (M0286): the server
/// re-encoded, square, transparent PNG shown beside units, calls and POIs of that type in lists, pickers and on the map,
- /// and on the map pins of the role's members. One row per type;
+ /// and on the map pins of the role's members. A unit, call type or role can also have one icon per status or priority
+ /// (, M0289), shown on the map instead of the type's own icon while that status or priority holds.
+ /// One row per type and slot;
/// a new upload replaces the row under a new IconKey, so the anonymous icon URL is immutable and caches forever. No
/// foreign keys: the type deletes and the department purge remove the rows.
///
@@ -63,6 +87,14 @@ public class TypeIcon : IEntity
[Required]
public string IconKey { get; set; }
+ ///
+ /// Null for the type's own icon. Otherwise the status or priority this icon stands in for (M0289), as built by
+ /// , or
+ /// : a call type's icon while its call has one priority, a unit type's or
+ /// role's while the unit or member is in one status. Only the type's own icon is mirrored on the type.
+ ///
+ public string Variant { get; set; }
+
public string ContentType { get; set; }
public int Width { get; set; }
@@ -152,6 +184,130 @@ public static string WebIconUrl(string iconKey)
return $"{WebIconPath}{iconKey}";
}
+ ///
+ /// The uploaded icon's URL on the API host with a badge drawn on it (the type's own icon
+ /// standing in for a status or priority it has no icon of its own for); the plain URL when the style is null.
+ ///
+ public static string ApiIconUrl(string apiBaseUrl, string iconKey, string badgeStyle) =>
+ WithBadge(ApiIconUrl(apiBaseUrl, iconKey), badgeStyle);
+
+ /// The web app twin of .
+ public static string WebIconUrl(string iconKey, string badgeStyle) => WithBadge(WebIconUrl(iconKey), badgeStyle);
+
+ private static string WithBadge(string url, string badgeStyle)
+ {
+ if (url == null || !MapIconStyles.IsStyle(badgeStyle))
+ return url;
+
+ return $"{url}?s={badgeStyle}&v={MapIconStyles.Version}";
+ }
+
+ /// Path of the anonymous built-in pin endpoint on the API host.
+ public const string ApiPinPath = "/api/v4/TypeIcons/Pins/";
+
+ /// Path of the same endpoint on the web app.
+ public const string WebPinPath = "/TypeIcons/Pins/";
+
+ /// Built-in pin a call takes when its type picked no built-in icon (the Call.png artwork).
+ public const string CallPinBase = "call";
+
+ /// Built-in pin a unit takes when its type picked no built-in icon (the Engine artwork).
+ public const string UnitPinBase = "engine";
+
+ /// Built-in pin a member takes (the Person artwork).
+ public const string PersonPinBase = "person";
+
+ /// The bundled icon name older clients show for a call, unit or person with no built-in icon of its type.
+ public const string LegacyCallImage = "Call";
+ public const string LegacyUnitImage = "Engine_Responding";
+ public const string LegacyPersonImage = "Person_RespondingCall";
+
+ /// The name of a built-in pin in a style: "fire-high", "ambulance-available", "person-onscene".
+ public static string PinName(string pinBase, string style) => $"{pinBase}-{style}";
+
+ ///
+ /// Every built-in pin artwork a styled pin can be drawn from: the call and unit icons a type can pick plus the
+ /// default call, unit and person pins.
+ ///
+ public static readonly IReadOnlyCollection PinBases = new HashSet(
+ Enum.GetValues(typeof(MapIconTypes)).Cast().Select(MapIcons.ConvertTypeToName).Where(x => !string.IsNullOrEmpty(x))
+ .Concat(new[] { CallPinBase, UnitPinBase, PersonPinBase }), StringComparer.Ordinal);
+
+ /// Splits a pin name into its artwork and style; false for anything that is not a known pin.
+ public static bool TryParsePinName(string pinName, out string pinBase, out string style)
+ {
+ pinBase = null;
+ style = null;
+
+ if (string.IsNullOrEmpty(pinName) || pinName.Length > 64)
+ return false;
+
+ var dash = pinName.LastIndexOf('-');
+ if (dash <= 0 || dash == pinName.Length - 1)
+ return false;
+
+ var candidateBase = pinName.Substring(0, dash);
+ var candidateStyle = pinName.Substring(dash + 1);
+
+ if (!PinBases.Contains(candidateBase) || !MapIconStyles.IsStyle(candidateStyle))
+ return false;
+
+ pinBase = candidateBase;
+ style = candidateStyle;
+ return true;
+ }
+
+ /// A styled built-in pin's absolute URL on the API host (a 32 x 37 PNG whose tip is the position).
+ public static string ApiPinUrl(string apiBaseUrl, string pinName)
+ {
+ if (string.IsNullOrWhiteSpace(pinName))
+ return null;
+
+ return $"{(apiBaseUrl ?? string.Empty).TrimEnd('/')}{ApiPinPath}{pinName}?v={MapIconStyles.Version}";
+ }
+
+ /// A styled built-in pin's root-relative URL on the web app.
+ public static string WebPinUrl(string pinName)
+ {
+ if (string.IsNullOrWhiteSpace(pinName))
+ return null;
+
+ return $"{WebPinPath}{pinName}?v={MapIconStyles.Version}";
+ }
+
+ // p = call priority value, s = built-in status value, o = a department's custom status option id.
+ private static readonly Regex VariantFormat = new Regex("^[pso](0|[1-9][0-9]{0,8})$",RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ /// The icon slot of a call priority (its stored value: a built-in priority or the department's priority id).
+ public static string PriorityVariant(int priority) => $"p{priority}";
+
+ /// The icon slot of a built-in unit or personnel status value.
+ public static string BuiltInStatusVariant(int status) => $"s{status}";
+
+ /// The icon slot of one of the department's custom status options (its CustomStateDetailId).
+ public static string CustomStatusVariant(int customStateDetailId) => $"o{customStateDetailId}";
+
+ ///
+ /// True when is a slot can have: priorities for call types,
+ /// statuses for unit types and roles, none for POI types.
+ ///
+ public static bool IsValidVariant(TypeIconKinds kind, string variant)
+ {
+ if (string.IsNullOrEmpty(variant) || !VariantFormat.IsMatch(variant))
+ return false;
+
+ switch (kind)
+ {
+ case TypeIconKinds.CallType:
+ return variant[0] == 'p';
+ case TypeIconKinds.UnitType:
+ case TypeIconKinds.PersonnelRole:
+ return variant[0] == 's' || (variant[0] == 'o' && variant != "o0");
+ default:
+ return false;
+ }
+ }
+
/// The built-in map icon name a unit type picked (e.g. "ambulance"), or null when it has none.
public static string BuiltInIconName(UnitType type) => BuiltInIconName(type?.MapIconType);
diff --git a/Core/Resgrid.Services/CustomStateService.cs b/Core/Resgrid.Services/CustomStateService.cs
index 5fdc44fbc..4cd0cef37 100644
--- a/Core/Resgrid.Services/CustomStateService.cs
+++ b/Core/Resgrid.Services/CustomStateService.cs
@@ -372,11 +372,28 @@ public async Task GetCustomPersonnelStatusAsync(int departmen
return raw;
var rawMeaning = option != null ? option.BaseType : (int)BuiltInStatusBaseTypes.ForPersonnelStatus(raw);
- return rawMeaning != (int)ActionBaseTypes.None && Enum.IsDefined(typeof(ActionBaseTypes), rawMeaning)
+ var byMeaning = rawMeaning != (int)ActionBaseTypes.None && Enum.IsDefined(typeof(ActionBaseTypes), rawMeaning)
? await ResolvePersonnelStatusAsync(departmentId, (ActionBaseTypes)rawMeaning)
: null;
+ if (byMeaning.HasValue)
+ return byMeaning;
+
+ // No status has that meaning (options created before meanings all read None): one of those named like the saved
+ // status ("Standing By", the deleted option's text), or whose name suggests its meaning, stands in for it.
+ var byName = personnelSet != null
+ ? StatusNameMatch.Find(personnelSet.GetActiveDetails(), CustomStateTypes.Personnel, option?.ButtonText ?? BuiltInName(GetDefaultPersonStatuses(), raw), rawMeaning)
+ : null;
+ if (byName != null)
+ return byName.CustomStateDetailId;
+
+ // Nothing does: keep resetting to the value the reset wrote before meanings rather than skipping everyone. Only a
+ // value that is neither built-in nor one of the department's own options is dropped.
+ return option != null || Enum.IsDefined(typeof(ActionTypes), raw) ? raw : (int?)null;
}
+ private static string BuiltInName(List builtIns, int raw) =>
+ builtIns.FirstOrDefault(x => x.CustomStateDetailId == raw)?.ButtonText;
+
public async Task ResolveStaffingResetAsync(int departmentId, string target)
{
var meaning = StatusMatch.BaseTypeOf(target);
@@ -386,7 +403,9 @@ public async Task GetCustomPersonnelStatusAsync(int departmen
if (!int.TryParse(target?.Trim(), out var raw))
return null;
- // Same rule as statuses: a value that is still one of the department's levels is kept, anything else follows its meaning.
+ // Same rule as statuses: a value that is still one of the department's levels is kept, anything else follows its
+ // meaning, then a level without a meaning matched by name, and with none of those the value is written as it was
+ // before meanings.
var staffingSet = await GetActiveStaffingLevelsForDepartmentAsync(departmentId);
var option = await FindDepartmentOptionAsync(departmentId, CustomStateTypes.Staffing, raw);
@@ -397,7 +416,17 @@ public async Task GetCustomPersonnelStatusAsync(int departmen
return raw;
var rawMeaning = option != null ? option.BaseType : (int)BuiltInStatusBaseTypes.ForStaffing(raw);
- return Enum.IsDefined(typeof(StaffingBaseTypes), rawMeaning) ? await ResolvePersonnelStaffingAsync(departmentId, (StaffingBaseTypes)rawMeaning) : null;
+ var byMeaning = Enum.IsDefined(typeof(StaffingBaseTypes), rawMeaning) ? await ResolvePersonnelStaffingAsync(departmentId, (StaffingBaseTypes)rawMeaning) : null;
+ if (byMeaning.HasValue)
+ return byMeaning;
+
+ var byName = staffingSet != null
+ ? StatusNameMatch.Find(staffingSet.GetActiveDetails(), CustomStateTypes.Staffing, option?.ButtonText ?? BuiltInName(GetDefaultPersonStaffings(), raw), rawMeaning)
+ : null;
+ if (byName != null)
+ return byName.CustomStateDetailId;
+
+ return option != null || Enum.IsDefined(typeof(UserStateTypes), raw) ? raw : (int?)null;
}
public async Task GetDefaultPersonnelStatusAsync(int departmentId)
diff --git a/Core/Resgrid.Services/MapIconPinRenderer.cs b/Core/Resgrid.Services/MapIconPinRenderer.cs
new file mode 100644
index 000000000..2bf74830e
--- /dev/null
+++ b/Core/Resgrid.Services/MapIconPinRenderer.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using Resgrid.Model;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Draws the styled built-in map pins and the status badges on uploaded type icons, on request, for the anonymous pin
+ /// and icon endpoints of the web app and the API.
+ ///
+ /// A styled pin is the built-in pin artwork (Resources/MapPins, the same 32 x 37 PNGs the web map and the apps bundle)
+ /// recoloured to the style's colour: the pin body's own colour is found, every darker pixel (its border and shading)
+ /// keeps its shade of the new colour and every lighter one (the white glyph and its anti-aliasing) keeps its blend
+ /// towards white. A badge is a ring in the style's colour at the lower right corner of an uploaded 128 px icon, so a type
+ /// with only its own icon still shows the status or priority at a glance.
+ ///
+ /// Both are deterministic, so results are cached for the life of the process; the pin set is bounded (artworks x styles)
+ /// and the badge cache is capped.
+ ///
+ public static class MapIconPinRenderer
+ {
+ private const string ResourcePrefix = "Resgrid.Services.MapPins.";
+ private const int BadgeCacheLimit = 2000;
+
+ private static readonly ConcurrentDictionary PinCache = new ConcurrentDictionary(StringComparer.Ordinal);
+ private static readonly ConcurrentDictionary BadgeCache = new ConcurrentDictionary(StringComparer.Ordinal);
+
+ /// True when a pin artwork of that name ships with the renderer.
+ public static bool HasArtwork(string pinBase) =>
+ pinBase != null && typeof(MapIconPinRenderer).Assembly.GetManifestResourceInfo(ResourcePrefix + pinBase + ".png") != null;
+
+ /// The styled pin named ("fire-high") as PNG bytes, or null when there is no such pin.
+ public static byte[] RenderPin(string pinName)
+ {
+ if (!TypeIcons.TryParsePinName(pinName, out var pinBase, out var style) || !HasArtwork(pinBase))
+ return null;
+
+ return PinCache.GetOrAdd(pinName, _ => DrawPin(pinBase, style));
+ }
+
+ ///
+ /// (an uploaded icon, already a square PNG) with a badge, as PNG
+ /// bytes; (the icon's checksum) keys the cache. Null for an unknown style or unreadable image.
+ ///
+ public static byte[] RenderBadge(byte[] iconPng, string style, string cacheKey)
+ {
+ if (iconPng == null || iconPng.Length == 0 || !MapIconStyles.IsStyle(style))
+ return null;
+
+ var key = $"{cacheKey}:{style}";
+ if (cacheKey != null && BadgeCache.TryGetValue(key, out var cached))
+ return cached;
+
+ byte[] rendered;
+ try
+ {
+ rendered = DrawBadge(iconPng, style);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ if (cacheKey != null)
+ {
+ if (BadgeCache.Count >= BadgeCacheLimit)
+ BadgeCache.Clear();
+
+ BadgeCache[key] = rendered;
+ }
+
+ return rendered;
+ }
+
+ private static byte[] DrawPin(string pinBase, string style)
+ {
+ using var stream = typeof(MapIconPinRenderer).Assembly.GetManifestResourceStream(ResourcePrefix + pinBase + ".png");
+ using var image = Image.Load(stream);
+
+ var target = ParseColor(MapIconStyles.ColorOf(style));
+ var body = DominantBodyColor(image);
+ var bodyLuminance = Luminance(body);
+
+ image.ProcessPixelRows(accessor =>
+ {
+ for (var y = 0; y < accessor.Height; y++)
+ {
+ var row = accessor.GetRowSpan(y);
+ for (var x = 0; x < row.Length; x++)
+ {
+ var pixel = row[x];
+ if (pixel.A == 0)
+ continue;
+
+ var luminance = Luminance(pixel);
+ if (luminance >= bodyLuminance)
+ {
+ // The glyph and its edges: the same blend towards white, over the new colour.
+ var t = bodyLuminance >= 255 ? 1d : Math.Min(1d, (luminance - bodyLuminance) / (255d - bodyLuminance));
+ row[x] = new Rgba32(Blend(target.R, 255, t), Blend(target.G, 255, t), Blend(target.B, 255, t), pixel.A);
+ }
+ else
+ {
+ // The border and shading: the same shade of the new colour.
+ var k = bodyLuminance <= 0 ? 0d : Math.Max(0d, luminance / bodyLuminance);
+ row[x] = new Rgba32(Scale(target.R, k), Scale(target.G, k), Scale(target.B, k), pixel.A);
+ }
+ }
+ }
+ });
+
+ return EncodePng(image);
+ }
+
+ private static byte[] DrawBadge(byte[] iconPng, string style)
+ {
+ using var image = Image.Load(iconPng);
+
+ if (image.Width != image.Height || image.Width != TypeIcons.StoredSize)
+ image.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(TypeIcons.StoredSize, TypeIcons.StoredSize), Mode = ResizeMode.Pad, PadColor = Color.Transparent }));
+
+ var fill = ParseColor(MapIconStyles.ColorOf(style));
+ var white = new Rgba32(255, 255, 255, 255);
+
+ // At 32 px on the map the ring is about 12 px across: big enough to read, small enough to leave the icon visible.
+ var size = image.Width;
+ var outer = size * 0.19;
+ var inner = outer - size * 0.045;
+ var centre = size - outer - 1;
+
+ image.ProcessPixelRows(accessor =>
+ {
+ var top = (int)Math.Floor(centre - outer - 1);
+ for (var y = Math.Max(0, top); y < accessor.Height; y++)
+ {
+ var row = accessor.GetRowSpan(y);
+ for (var x = Math.Max(0, top); x < row.Length; x++)
+ {
+ var distance = Math.Sqrt(Math.Pow(x + 0.5 - centre, 2) + Math.Pow(y + 0.5 - centre, 2));
+ var outerCoverage = Math.Clamp(outer - distance + 0.5, 0d, 1d);
+ if (outerCoverage <= 0)
+ continue;
+
+ var innerCoverage = Math.Clamp(inner - distance + 0.5, 0d, 1d);
+ var ring = Mix(white, fill, innerCoverage);
+ row[x] = Over(ring, outerCoverage, row[x]);
+ }
+ }
+ });
+
+ return EncodePng(image);
+ }
+
+ private static Rgba32 DominantBodyColor(Image image)
+ {
+ var counts = new Dictionary();
+ image.ProcessPixelRows(accessor =>
+ {
+ for (var y = 0; y < accessor.Height; y++)
+ {
+ foreach (var pixel in accessor.GetRowSpan(y))
+ {
+ // Opaque and not part of the white glyph.
+ if (pixel.A <= 250 || Math.Min(pixel.R, Math.Min(pixel.G, pixel.B)) >= 200)
+ continue;
+
+ var opaque = new Rgba32(pixel.R, pixel.G, pixel.B, 255);
+ counts[opaque] = counts.TryGetValue(opaque, out var count) ? count + 1 : 1;
+ }
+ }
+ });
+
+ return counts.Count == 0 ? new Rgba32(128, 128, 128, 255) : counts.OrderByDescending(x => x.Value).First().Key;
+ }
+
+ private static Rgba32 ParseColor(string hex) =>
+ new Rgba32(Convert.ToByte(hex.Substring(1, 2), 16), Convert.ToByte(hex.Substring(3, 2), 16), Convert.ToByte(hex.Substring(5, 2), 16), 255);
+
+ private static double Luminance(Rgba32 c) => 0.299 * c.R + 0.587 * c.G + 0.114 * c.B;
+
+ private static byte Blend(byte from, byte to, double t) => (byte)Math.Round(from + t * (to - from));
+
+ private static byte Scale(byte value, double k) => (byte)Math.Round(Math.Min(255d, value * k));
+
+ private static Rgba32 Mix(Rgba32 outside, Rgba32 inside, double insideCoverage) =>
+ new Rgba32(Blend(outside.R, inside.R, insideCoverage), Blend(outside.G, inside.G, insideCoverage), Blend(outside.B, inside.B, insideCoverage), 255);
+
+ // Source-over compositing of an opaque colour with partial coverage onto a straight-alpha pixel.
+ private static Rgba32 Over(Rgba32 source, double coverage, Rgba32 destination)
+ {
+ var destinationAlpha = destination.A / 255d;
+ var alpha = coverage + destinationAlpha * (1 - coverage);
+ if (alpha <= 0)
+ return destination;
+
+ byte Channel(byte s, byte d) => (byte)Math.Round((s * coverage + d * destinationAlpha * (1 - coverage)) / alpha);
+
+ return new Rgba32(Channel(source.R, destination.R), Channel(source.G, destination.G), Channel(source.B, destination.B), (byte)Math.Round(alpha * 255));
+ }
+
+ private static byte[] EncodePng(Image image)
+ {
+ using var output = new MemoryStream();
+ image.Save(output, new PngEncoder { ColorType = PngColorType.RgbWithAlpha, SkipMetadata = true });
+ return output.ToArray();
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/MapIconsService.cs b/Core/Resgrid.Services/MapIconsService.cs
new file mode 100644
index 000000000..d1650fd0e
--- /dev/null
+++ b/Core/Resgrid.Services/MapIconsService.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class MapIconsService : IMapIconsService
+ {
+ private readonly ICallsService _callsService;
+ private readonly IUnitsService _unitsService;
+ private readonly ICustomStateService _customStateService;
+ private readonly IPersonnelRolesService _personnelRolesService;
+ private readonly ITypeIconsService _typeIconsService;
+
+ public MapIconsService(ICallsService callsService, IUnitsService unitsService, ICustomStateService customStateService,
+ IPersonnelRolesService personnelRolesService, ITypeIconsService typeIconsService)
+ {
+ _callsService = callsService;
+ _unitsService = unitsService;
+ _customStateService = customStateService;
+ _personnelRolesService = personnelRolesService;
+ _typeIconsService = typeIconsService;
+ }
+
+ public async Task GetResolverAsync(int departmentId, IEnumerable latestUnitStates = null)
+ {
+ return new MapIconResolver(departmentId,
+ await _callsService.GetCallTypesForDepartmentAsync(departmentId),
+ // Deleted priorities included, so an older call keeps the meaning of the priority it was given.
+ await _callsService.GetCallPrioritiesForDepartmentAsync(departmentId),
+ await _unitsService.GetUnitTypesForDepartmentAsync(departmentId),
+ await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId),
+ await _personnelRolesService.GetRolesForDepartmentAsync(departmentId),
+ await _typeIconsService.GetVariantsForDepartmentAsync(departmentId),
+ await _customStateService.GetDefaultPersonnelStatusAsync(departmentId),
+ latestUnitStates);
+ }
+
+ public async Task GetLegendAsync(int departmentId, string noCallTypeName, string noUnitTypeName, string noRoleName)
+ {
+ return MapIconLegendBuilder.Build(await GetResolverAsync(departmentId),
+ await _callsService.GetActiveCallPrioritiesForDepartmentAsync(departmentId),
+ await _customStateService.GetAllCustomStatesForDepartmentAsync(departmentId),
+ _customStateService.GetDefaultUnitStatuses(),
+ await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(departmentId),
+ await _personnelRolesService.GetRolesForDepartmentAsync(departmentId),
+ noCallTypeName, noUnitTypeName, noRoleName);
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj
index 9dbcd42d7..5ca469768 100644
--- a/Core/Resgrid.Services/Resgrid.Services.csproj
+++ b/Core/Resgrid.Services/Resgrid.Services.csproj
@@ -6,6 +6,10 @@
+
+
+
+
diff --git a/Core/Resgrid.Services/Resources/MapPins/aircraft.png b/Core/Resgrid.Services/Resources/MapPins/aircraft.png
new file mode 100644
index 000000000..7617d5040
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/aircraft.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/ambulance.png b/Core/Resgrid.Services/Resources/MapPins/ambulance.png
new file mode 100644
index 000000000..03cc1d994
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/ambulance.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/blast.png b/Core/Resgrid.Services/Resources/MapPins/blast.png
new file mode 100644
index 000000000..6a35aa506
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/blast.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/bulldozer.png b/Core/Resgrid.Services/Resources/MapPins/bulldozer.png
new file mode 100644
index 000000000..d9fadb0ee
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/bulldozer.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/bus.png b/Core/Resgrid.Services/Resources/MapPins/bus.png
new file mode 100644
index 000000000..651a9d22e
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/bus.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/call.png b/Core/Resgrid.Services/Resources/MapPins/call.png
new file mode 100644
index 000000000..b1fe48ad3
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/call.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/camper.png b/Core/Resgrid.Services/Resources/MapPins/camper.png
new file mode 100644
index 000000000..67bc93e4c
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/camper.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/car.png b/Core/Resgrid.Services/Resources/MapPins/car.png
new file mode 100644
index 000000000..4fe29b076
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/car.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/caraccident.png b/Core/Resgrid.Services/Resources/MapPins/caraccident.png
new file mode 100644
index 000000000..612e25927
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/caraccident.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/cartwo.png b/Core/Resgrid.Services/Resources/MapPins/cartwo.png
new file mode 100644
index 000000000..256ac40b1
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/cartwo.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/check.png b/Core/Resgrid.Services/Resources/MapPins/check.png
new file mode 100644
index 000000000..980ceb86e
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/check.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/crimescene.png b/Core/Resgrid.Services/Resources/MapPins/crimescene.png
new file mode 100644
index 000000000..028c12967
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/crimescene.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/earthquake.png b/Core/Resgrid.Services/Resources/MapPins/earthquake.png
new file mode 100644
index 000000000..cd3bf31ef
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/earthquake.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/emergencyphone.png b/Core/Resgrid.Services/Resources/MapPins/emergencyphone.png
new file mode 100644
index 000000000..823481957
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/emergencyphone.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/engine.png b/Core/Resgrid.Services/Resources/MapPins/engine.png
new file mode 100644
index 000000000..d80eadd6f
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/engine.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/fire.png b/Core/Resgrid.Services/Resources/MapPins/fire.png
new file mode 100644
index 000000000..0aacb5808
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/fire.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/firstaid.png b/Core/Resgrid.Services/Resources/MapPins/firstaid.png
new file mode 100644
index 000000000..9de8f23ef
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/firstaid.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/flag.png b/Core/Resgrid.Services/Resources/MapPins/flag.png
new file mode 100644
index 000000000..08f0d0bff
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/flag.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/flood.png b/Core/Resgrid.Services/Resources/MapPins/flood.png
new file mode 100644
index 000000000..af1750787
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/flood.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/fourbyfour.png b/Core/Resgrid.Services/Resources/MapPins/fourbyfour.png
new file mode 100644
index 000000000..f03cad5b5
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/fourbyfour.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/gathering.png b/Core/Resgrid.Services/Resources/MapPins/gathering.png
new file mode 100644
index 000000000..23a734ebd
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/gathering.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/group.png b/Core/Resgrid.Services/Resources/MapPins/group.png
new file mode 100644
index 000000000..61a6dd327
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/group.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/helicopter.png b/Core/Resgrid.Services/Resources/MapPins/helicopter.png
new file mode 100644
index 000000000..439af81fc
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/helicopter.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/industry.png b/Core/Resgrid.Services/Resources/MapPins/industry.png
new file mode 100644
index 000000000..cc0e174a9
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/industry.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/linedown.png b/Core/Resgrid.Services/Resources/MapPins/linedown.png
new file mode 100644
index 000000000..3693e03af
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/linedown.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/motorcycle.png b/Core/Resgrid.Services/Resources/MapPins/motorcycle.png
new file mode 100644
index 000000000..0ce8cf8ed
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/motorcycle.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/person.png b/Core/Resgrid.Services/Resources/MapPins/person.png
new file mode 100644
index 000000000..88712e703
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/person.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/pickup.png b/Core/Resgrid.Services/Resources/MapPins/pickup.png
new file mode 100644
index 000000000..91dcd10ac
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/pickup.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/plowtruck.png b/Core/Resgrid.Services/Resources/MapPins/plowtruck.png
new file mode 100644
index 000000000..211e2153e
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/plowtruck.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/poison.png b/Core/Resgrid.Services/Resources/MapPins/poison.png
new file mode 100644
index 000000000..4ef80a776
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/poison.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/poweroutage.png b/Core/Resgrid.Services/Resources/MapPins/poweroutage.png
new file mode 100644
index 000000000..42d390a35
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/poweroutage.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/radiation.png b/Core/Resgrid.Services/Resources/MapPins/radiation.png
new file mode 100644
index 000000000..bebf8950a
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/radiation.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/search.png b/Core/Resgrid.Services/Resources/MapPins/search.png
new file mode 100644
index 000000000..c7f3348b0
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/search.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/shooting.png b/Core/Resgrid.Services/Resources/MapPins/shooting.png
new file mode 100644
index 000000000..df1b5b2f5
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/shooting.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/tires.png b/Core/Resgrid.Services/Resources/MapPins/tires.png
new file mode 100644
index 000000000..581670267
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/tires.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/tools.png b/Core/Resgrid.Services/Resources/MapPins/tools.png
new file mode 100644
index 000000000..b421c84f7
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/tools.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/treedown.png b/Core/Resgrid.Services/Resources/MapPins/treedown.png
new file mode 100644
index 000000000..77933ce04
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/treedown.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/truck.png b/Core/Resgrid.Services/Resources/MapPins/truck.png
new file mode 100644
index 000000000..9a4d4479d
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/truck.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/van.png b/Core/Resgrid.Services/Resources/MapPins/van.png
new file mode 100644
index 000000000..3c499eb78
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/van.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/velocimeter.png b/Core/Resgrid.Services/Resources/MapPins/velocimeter.png
new file mode 100644
index 000000000..bd1786df6
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/velocimeter.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/watercraft.png b/Core/Resgrid.Services/Resources/MapPins/watercraft.png
new file mode 100644
index 000000000..a76dd1bcb
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/watercraft.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/workshop.png b/Core/Resgrid.Services/Resources/MapPins/workshop.png
new file mode 100644
index 000000000..1990d3190
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/workshop.png differ
diff --git a/Core/Resgrid.Services/Resources/MapPins/worksite.png b/Core/Resgrid.Services/Resources/MapPins/worksite.png
new file mode 100644
index 000000000..47cd84a18
Binary files /dev/null and b/Core/Resgrid.Services/Resources/MapPins/worksite.png differ
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 6e16f85f7..a1e13741e 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -562,6 +562,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
}
diff --git a/Core/Resgrid.Services/TypeIconsService.cs b/Core/Resgrid.Services/TypeIconsService.cs
index dc50a5bd9..92976e791 100644
--- a/Core/Resgrid.Services/TypeIconsService.cs
+++ b/Core/Resgrid.Services/TypeIconsService.cs
@@ -1,9 +1,11 @@
using System;
+using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model;
+using Resgrid.Model.Providers;
using Resgrid.Model.Repositories;
using Resgrid.Model.Services;
@@ -14,11 +16,16 @@ public class TypeIconsService : ITypeIconsService
// IconKeys are Guid "N" strings; anything else is refused before it reaches the database.
private static readonly Regex IconKeyFormat = new Regex("^[0-9a-f]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ private static string VariantsCacheKey = "TypeIconVariants_{0}";
+ private static TimeSpan VariantsCacheLength = TimeSpan.FromDays(1);
+
private readonly ITypeIconsRepository _typeIconsRepository;
+ private readonly ICacheProvider _cacheProvider;
- public TypeIconsService(ITypeIconsRepository typeIconsRepository)
+ public TypeIconsService(ITypeIconsRepository typeIconsRepository, ICacheProvider cacheProvider)
{
_typeIconsRepository = typeIconsRepository;
+ _cacheProvider = cacheProvider;
}
public TypeIconRejections ValidateIcon(byte[] data)
@@ -39,36 +46,22 @@ public async Task UploadIconAsync(int departmentId, TypeIconKinds kind,
if (!Enum.IsDefined(typeof(TypeIconKinds), kind))
throw new ArgumentOutOfRangeException(nameof(kind));
- var rendition = TypeIconRenditions.Build(data);
-
- var icon = new TypeIcon
- {
- TypeIconId = Guid.NewGuid().ToString(),
- DepartmentId = departmentId,
- TypeKind = (int)kind,
- TypeId = typeId,
- IconKey = Guid.NewGuid().ToString("N"),
- ContentType = rendition.ContentType,
- Width = rendition.Width,
- Height = rendition.Height,
- ByteSize = rendition.Data.Length,
- Checksum = Checksum(rendition.Data),
- Data = rendition.Data,
- UploadedByUserId = userId,
- UploadedOn = DateTime.UtcNow
- };
+ var icon = BuildIcon(departmentId, kind, typeId, null, userId, TypeIconRenditions.Build(data));
- // New row first, then point the type at it (the update is scoped to the department, so it also proves
- // ownership), then drop the replaced rows: a failure part way leaves the type on a working icon, old or new.
+ // New row first, then point the type at the newest icon of its slot (the update is scoped to the department, so it
+ // also proves ownership), then drop the older rows: a failure part way leaves the type on a working icon, old or new.
+ // The newest, not "this one": two uploads at once would each delete the other's row and leave the type on a key
+ // that no longer exists, while each step here keeps the newest row, so whichever runs last leaves the type on it.
+ // Only the type's own icon slot is touched; its status and priority icons stay.
await _typeIconsRepository.InsertAsync(icon, cancellationToken);
- if (await _typeIconsRepository.SetTypeIconKeyAsync(departmentId, kind, typeId, icon.IconKey, cancellationToken) == 0)
+ if (await _typeIconsRepository.SetTypeIconKeyToNewestAsync(departmentId, kind, typeId, cancellationToken) == 0)
{
- await _typeIconsRepository.DeleteForTypeAsync(departmentId, kind, typeId, null, cancellationToken);
+ await _typeIconsRepository.DeleteSlotAsync(departmentId, kind, typeId, null, null, cancellationToken);
return null;
}
- await _typeIconsRepository.DeleteForTypeAsync(departmentId, kind, typeId, icon.IconKey, cancellationToken);
+ await _typeIconsRepository.DeleteAllButNewestInSlotAsync(departmentId, kind, typeId, null, cancellationToken);
return icon.IconKey;
}
@@ -81,10 +74,87 @@ public async Task RemoveIconAsync(int departmentId, TypeIconKinds kind, in
if (await _typeIconsRepository.SetTypeIconKeyAsync(departmentId, kind, typeId, null, cancellationToken) == 0)
return false;
- await _typeIconsRepository.DeleteForTypeAsync(departmentId, kind, typeId, null, cancellationToken);
+ await _typeIconsRepository.DeleteSlotAsync(departmentId, kind, typeId, null, null, cancellationToken);
+ return true;
+ }
+
+ public async Task UploadVariantIconAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, string userId, byte[] data, CancellationToken cancellationToken = default)
+ {
+ if (!Enum.IsDefined(typeof(TypeIconKinds), kind))
+ throw new ArgumentOutOfRangeException(nameof(kind));
+
+ if (!TypeIcons.IsValidVariant(kind, variant))
+ return null;
+
+ var rendition = TypeIconRenditions.Build(data);
+
+ // Nothing on the type points at a status or priority icon, so ownership is proven up front.
+ if (!await _typeIconsRepository.TypeBelongsToDepartmentAsync(departmentId, kind, typeId))
+ return null;
+
+ var icon = BuildIcon(departmentId, kind, typeId, variant, userId, rendition);
+
+ // The slot keeps its newest upload rather than "this one": two uploads at once would each delete the other's row.
+ await _typeIconsRepository.InsertAsync(icon, cancellationToken);
+ await _typeIconsRepository.DeleteAllButNewestInSlotAsync(departmentId, kind, typeId, variant, cancellationToken);
+ await InvalidateVariantsAsync(departmentId);
+
+ return icon.IconKey;
+ }
+
+ public async Task RemoveVariantIconAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, CancellationToken cancellationToken = default)
+ {
+ if (!Enum.IsDefined(typeof(TypeIconKinds), kind))
+ throw new ArgumentOutOfRangeException(nameof(kind));
+
+ if (!TypeIcons.IsValidVariant(kind, variant) || !await _typeIconsRepository.TypeBelongsToDepartmentAsync(departmentId, kind, typeId))
+ return false;
+
+ await _typeIconsRepository.DeleteSlotAsync(departmentId, kind, typeId, variant, null, cancellationToken);
+ await InvalidateVariantsAsync(departmentId);
+
return true;
}
+ public async Task> GetVariantsForDepartmentAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task> getVariants()
+ {
+ return await _typeIconsRepository.GetVariantsForDepartmentAsync(departmentId) ?? new List();
+ }
+
+ if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled)
+ return await _cacheProvider.RetrieveAsync(string.Format(VariantsCacheKey, departmentId), getVariants, VariantsCacheLength) ?? new List();
+
+ return await getVariants();
+ }
+
+ private async Task InvalidateVariantsAsync(int departmentId)
+ {
+ await _cacheProvider.RemoveAsync(string.Format(VariantsCacheKey, departmentId));
+ }
+
+ private static TypeIcon BuildIcon(int departmentId, TypeIconKinds kind, int typeId, string variant, string userId, TypeIconRendition rendition)
+ {
+ return new TypeIcon
+ {
+ TypeIconId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ TypeKind = (int)kind,
+ TypeId = typeId,
+ IconKey = Guid.NewGuid().ToString("N"),
+ Variant = variant,
+ ContentType = rendition.ContentType,
+ Width = rendition.Width,
+ Height = rendition.Height,
+ ByteSize = rendition.Data.Length,
+ Checksum = Checksum(rendition.Data),
+ Data = rendition.Data,
+ UploadedByUserId = userId,
+ UploadedOn = DateTime.UtcNow
+ };
+ }
+
public async Task GetIconByKeyAsync(string iconKey)
{
if (string.IsNullOrEmpty(iconKey) || !IconKeyFormat.IsMatch(iconKey))
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0289_AddTypeIconVariants.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0289_AddTypeIconVariants.cs
new file mode 100644
index 000000000..ffcb7afb8
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0289_AddTypeIconVariants.cs
@@ -0,0 +1,24 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ ///
+ /// Status and priority map icons (extends M0285's type icons): TypeIcons.Variant names the status or priority an icon
+ /// stands in for (p{priority} on a call type, s{built-in status} or o{custom option id} on a unit type or role), NULL for
+ /// the type's own icon, so every existing row stays the type's own icon. Only the own icon is mirrored on the type.
+ ///
+ [Migration(289)]
+ public class M0289_AddTypeIconVariants : Migration
+ {
+ public override void Up()
+ {
+ Execute.Sql("IF COL_LENGTH('TypeIcons', 'Variant') IS NULL ALTER TABLE [TypeIcons] ADD [Variant] nvarchar(32) NULL;");
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table("TypeIcons").Column("Variant").Exists())
+ Delete.Column("Variant").FromTable("TypeIcons");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0289_AddTypeIconVariantsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0289_AddTypeIconVariantsPg.cs
new file mode 100644
index 000000000..4828afc4e
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0289_AddTypeIconVariantsPg.cs
@@ -0,0 +1,23 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ ///
+ /// Status and priority map icons: typeicons.variant (p{priority} on a call type, s{built-in status} or o{custom option
+ /// id} on a unit type or role; NULL for the type's own icon). PostgreSQL twin of the SQL Server migration; varchar like
+ /// M0285's iconkey so the string parameter compares exactly.
+ ///
+ [Migration(289)]
+ public class M0289_AddTypeIconVariantsPg : Migration
+ {
+ public override void Up()
+ {
+ Execute.Sql("ALTER TABLE typeicons ADD COLUMN IF NOT EXISTS variant varchar(32) NULL;");
+ }
+
+ public override void Down()
+ {
+ Execute.Sql("ALTER TABLE typeicons DROP COLUMN IF EXISTS variant;");
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.DataRepository/TypeIconsRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/TypeIconsRepository.cs
index 0f4884858..6e77f3fa6 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/TypeIconsRepository.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/TypeIconsRepository.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Resgrid.Model;
@@ -31,21 +33,83 @@ public Task DeleteForTypeAsync(int departmentId, TypeIconKinds kind, int ty
new { DepartmentId = departmentId, TypeKind = (int)kind, TypeId = typeId, KeepIconKey = keepIconKey }, cancellationToken);
}
+ public Task DeleteSlotAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, string keepIconKey = null, CancellationToken cancellationToken = default)
+ {
+ var slot = variant == null ? $"{Col("Variant")} IS NULL" : $"{Col("Variant")} = {P}Variant";
+ var keep = keepIconKey == null ? string.Empty : $" AND {Col("IconKey")} <> {P}KeepIconKey";
+
+ return ExecuteAsync(
+ $"DELETE FROM {Tbl("TypeIcons")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("TypeKind")} = {P}TypeKind AND {Col("TypeId")} = {P}TypeId AND {slot}{keep}",
+ new { DepartmentId = departmentId, TypeKind = (int)kind, TypeId = typeId, Variant = variant, KeepIconKey = keepIconKey }, cancellationToken);
+ }
+
+ public Task DeleteAllButNewestInSlotAsync(int departmentId, TypeIconKinds kind, int typeId, string variant, CancellationToken cancellationToken = default)
+ {
+ // A row goes only when the slot holds a newer one, so the newest is never deleted, by this call or a concurrent one.
+ return ExecuteAsync(
+ $"DELETE FROM {Tbl("TypeIcons")} WHERE {Col("TypeIconId")} IN (SELECT o.{Col("TypeIconId")} FROM {Tbl("TypeIcons")} o WHERE {InSlot("o", variant)} AND {NewerInSlot(variant)})",
+ new { DepartmentId = departmentId, TypeKind = (int)kind, TypeId = typeId, Variant = variant }, cancellationToken);
+ }
+
+ public Task SetTypeIconKeyToNewestAsync(int departmentId, TypeIconKinds kind, int typeId, CancellationToken cancellationToken = default)
+ {
+ var (table, idColumn) = TypeTable(kind);
+
+ // IconKey is unique, so the newest row is a single row; an empty slot gives NULL.
+ return ExecuteAsync(
+ $"UPDATE {Tbl(table)} SET {Col("IconKey")} = (SELECT o.{Col("IconKey")} FROM {Tbl("TypeIcons")} o WHERE {InSlot("o", null)} AND NOT {NewerInSlot(null)}) " +
+ $"WHERE {Col(idColumn)} = {P}TypeId AND {Col("DepartmentId")} = {P}DepartmentId",
+ new { DepartmentId = departmentId, TypeKind = (int)kind, TypeId = typeId }, cancellationToken);
+ }
+
+ // The rows of one icon slot under the alias: the type's own icon when variant is null, else that status or priority.
+ private string InSlot(string alias, string variant) =>
+ $"{alias}.{Col("DepartmentId")} = {P}DepartmentId AND {alias}.{Col("TypeKind")} = {P}TypeKind AND {alias}.{Col("TypeId")} = {P}TypeId AND " +
+ (variant == null ? $"{alias}.{Col("Variant")} IS NULL" : $"{alias}.{Col("Variant")} = {P}Variant");
+
+ // True when the slot holds a row newer than the one under alias "o": a later UploadedOn, then the greater IconKey.
+ private string NewerInSlot(string variant) =>
+ $"EXISTS (SELECT 1 FROM {Tbl("TypeIcons")} n WHERE {InSlot("n", variant)} AND (n.{Col("UploadedOn")} > o.{Col("UploadedOn")} " +
+ $"OR (n.{Col("UploadedOn")} = o.{Col("UploadedOn")} AND n.{Col("IconKey")} > o.{Col("IconKey")})))";
+
+ public async Task> GetVariantsForDepartmentAsync(int departmentId)
+ {
+ // Never the bytes: the map only needs which slots have an icon and under which key.
+ var rows = await QueryAsync(
+ $"SELECT {Col("TypeKind")}, {Col("TypeId")}, {Col("Variant")}, {Col("IconKey")} FROM {Tbl("TypeIcons")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {Col("Variant")} IS NOT NULL",
+ new { DepartmentId = departmentId });
+
+ return rows?.ToList() ?? new List();
+ }
+
+ public async Task TypeBelongsToDepartmentAsync(int departmentId, TypeIconKinds kind, int typeId)
+ {
+ var (table, idColumn) = TypeTable(kind);
+
+ var count = await QueryFirstOrDefaultAsync(
+ $"SELECT COUNT(1) FROM {Tbl(table)} WHERE {Col(idColumn)} = {P}TypeId AND {Col("DepartmentId")} = {P}DepartmentId",
+ new { TypeId = typeId, DepartmentId = departmentId });
+
+ return count > 0;
+ }
+
public Task SetTypeIconKeyAsync(int departmentId, TypeIconKinds kind, int typeId, string iconKey, CancellationToken cancellationToken = default)
{
- // The table and key column come from this fixed map, never from input.
- var (table, idColumn) = kind switch
- {
- TypeIconKinds.UnitType => ("UnitTypes", "UnitTypeId"),
- TypeIconKinds.CallType => ("CallTypes", "CallTypeId"),
- TypeIconKinds.PoiType => ("POITypes", "PoiTypeId"),
- TypeIconKinds.PersonnelRole => ("PersonnelRoles", "PersonnelRoleId"),
- _ => throw new ArgumentOutOfRangeException(nameof(kind))
- };
+ var (table, idColumn) = TypeTable(kind);
return ExecuteAsync(
$"UPDATE {Tbl(table)} SET {Col("IconKey")} = {P}IconKey WHERE {Col(idColumn)} = {P}TypeId AND {Col("DepartmentId")} = {P}DepartmentId",
new { IconKey = iconKey, TypeId = typeId, DepartmentId = departmentId }, cancellationToken);
}
+
+ // The table and key column come from this fixed map, never from input.
+ private static (string Table, string IdColumn) TypeTable(TypeIconKinds kind) => kind switch
+ {
+ TypeIconKinds.UnitType => ("UnitTypes", "UnitTypeId"),
+ TypeIconKinds.CallType => ("CallTypes", "CallTypeId"),
+ TypeIconKinds.PoiType => ("POITypes", "PoiTypeId"),
+ TypeIconKinds.PersonnelRole => ("PersonnelRoles", "PersonnelRoleId"),
+ _ => throw new ArgumentOutOfRangeException(nameof(kind))
+ };
}
}
diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs
index d7e0f368a..68a5dad02 100644
--- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs
+++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs
@@ -41,6 +41,8 @@ public class TranslationCompletenessTests
"Areas/User/Records/ReportSources",
// Import & Migration (Department Onboarding wizard, 2026-10-12) shipped fully translated.
"Areas/User/DepartmentImport/DepartmentImport",
+ // Map Icon Legend and the status / priority map icon pages (2026-10-24) shipped fully translated.
+ "Areas/User/Mapping/MapIconLegend",
};
private static string LocalizationRoot()
@@ -73,6 +75,12 @@ private static Dictionary Load(string path)
///
private static readonly HashSet KnownIdentical = new HashSet(StringComparer.Ordinal)
{
+ // Map Icon Legend (2026-10-24): the same word in the target language, checked side by side.
+ "MapIconLegend|de|StatusColumn", "MapIconLegend|pl|StatusColumn", "MapIconLegend|sv|StatusColumn", // "Status"
+ "MapIconLegend|de|Hydrant", "MapIconLegend|pl|Hydrant", // "Hydrant"
+ "MapIconLegend|sv|Station", // "Station"
+ "MapIconLegend|fr|Type", // "Type"
+ "MapIconLegend|fr|PersonnelHeader", // "Personnel"
// Report sources (2026-10-04): "Personnel" is the French word too.
"ReportSources|fr|TabPersonnel",
"ReportSources|fr|FilterPersonnel",
diff --git a/Tests/Resgrid.Tests/Models/StatusNameMatchTests.cs b/Tests/Resgrid.Tests/Models/StatusNameMatchTests.cs
new file mode 100644
index 000000000..69fd90e8a
--- /dev/null
+++ b/Tests/Resgrid.Tests/Models/StatusNameMatchTests.cs
@@ -0,0 +1,68 @@
+using System.Collections.Generic;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Model;
+
+namespace Resgrid.Tests.Models
+{
+ ///
+ /// A reset saved before meanings ("Standing By", "Unavailable", a deleted option) on a set whose options have no meaning
+ /// (RESGRID-WEBJOBS-BT / BV / C7): matched by name, only among options without a meaning.
+ ///
+ [TestFixture]
+ public class StatusNameMatchTests
+ {
+ private static CustomStateDetail Option(int id, string text, int baseType = -1) =>
+ new CustomStateDetail { CustomStateDetailId = id, ButtonText = text, BaseType = baseType };
+
+ [Test]
+ public void the_option_with_the_saved_name_wins()
+ {
+ var options = new List { Option(1, "Available"), Option(2, "Standing By"), Option(3, "Responding") };
+
+ StatusNameMatch.Find(options, CustomStateTypes.Personnel, "Standing By", (int)ActionBaseTypes.Available).CustomStateDetailId.Should().Be(2,
+ "the same name beats an earlier option that only suggests the same meaning");
+ StatusNameMatch.Find(options, CustomStateTypes.Personnel, "STANDING-BY", null).CustomStateDetailId.Should().Be(2);
+ }
+
+ [Test]
+ public void otherwise_the_first_option_whose_name_suggests_the_saved_meaning()
+ {
+ var options = new List { Option(1, "Responding"), Option(2, "In Service"), Option(3, "Available") };
+
+ StatusNameMatch.Find(options, CustomStateTypes.Personnel, "Standing By", (int)ActionBaseTypes.Available).CustomStateDetailId.Should().Be(2);
+ StatusNameMatch.Find(new[] { Option(10, "Duty Crew"), Option(11, "Away") }, CustomStateTypes.Staffing, "Unavailable", (int)StaffingBaseTypes.Unavailable)
+ .CustomStateDetailId.Should().Be(11);
+ }
+
+ [Test]
+ public void a_saved_value_without_a_meaning_uses_the_one_its_name_suggests()
+ {
+ // A deleted option "Avail" (no meaning) and the department's new "Available".
+ StatusNameMatch.Find(new[] { Option(1, "Off"), Option(2, "Available") }, CustomStateTypes.Staffing, "Avail", null).CustomStateDetailId.Should().Be(2);
+ }
+
+ [Test]
+ public void an_option_with_a_meaning_is_never_matched_by_name()
+ {
+ var options = new List { Option(1, "Standing By", (int)ActionBaseTypes.Standby), Option(2, "Available", (int)ActionBaseTypes.Unavailable) };
+
+ StatusNameMatch.Find(options, CustomStateTypes.Personnel, "Standing By", (int)ActionBaseTypes.Available).Should().BeNull();
+ }
+
+ [TestCase("Code 7", null)]
+ [TestCase("", null)]
+ [TestCase(null, null)]
+ public void no_name_and_no_meaning_match_nothing(string savedName, int? meaning)
+ {
+ StatusNameMatch.Find(new[] { Option(1, "Available"), Option(2, "Responding") }, CustomStateTypes.Personnel, savedName, meaning).Should().BeNull();
+ }
+
+ [Test]
+ public void no_options_match_nothing()
+ {
+ StatusNameMatch.Find(null, CustomStateTypes.Personnel, "Standing By", (int)ActionBaseTypes.Available).Should().BeNull();
+ StatusNameMatch.Find(new List(), CustomStateTypes.Staffing, "Unavailable", (int)StaffingBaseTypes.Unavailable).Should().BeNull();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Repositories/TypeIconsDatabaseTests.cs b/Tests/Resgrid.Tests/Repositories/TypeIconsDatabaseTests.cs
index b2781df95..caadee2c7 100644
--- a/Tests/Resgrid.Tests/Repositories/TypeIconsDatabaseTests.cs
+++ b/Tests/Resgrid.Tests/Repositories/TypeIconsDatabaseTests.cs
@@ -34,7 +34,10 @@ namespace Resgrid.Tests.Repositories
/// CallTypes, POITypes and PersonnelRoles, and PersonnelRoles.SortOrder (0 for existing roles); an icon round-trips through the generic insert and is found by its key (the service refuses
/// anything but lowercase hex first, since SQL Server's default collation compares case-insensitively); the type's key
/// is set only on the department's own row; a type's delete keeps only the key it is told to and the other kinds;
- /// and the role order is written only to the department's own roles.
+ /// and the role order is written only to the department's own roles. M0289: a status or priority icon (Variant) is its own
+ /// slot, so replacing or removing the type's own icon keeps it and replacing one slot keeps the others, while the type
+ /// delete removes them all; a slot's clean-up after an upload keeps its newest row and the type is pointed at its newest own
+ /// icon, so two uploads at once leave the type on the row that stays; the department's slots list without their bytes.
/// Set RESGRID_ADP_SQLSERVER_TEST_CONNECTION / RESGRID_ADP_POSTGRES_TEST_CONNECTION (server-level connections) to run.
///
[TestFixture(DatabaseTypes.SqlServer), TestFixture(DatabaseTypes.Postgres), NonParallelizable]
@@ -114,8 +117,8 @@ public async Task Create_isolated_database()
var source = new Mock();
source.Setup(s => s.GetMigrations()).Returns(IsPostgres
- ? new IMigration[] { new M0285_AddTypeIconsPg(), new M0286_AddPersonnelRoleIconsPg() }
- : new IMigration[] { new M0285_AddTypeIcons(), new M0286_AddPersonnelRoleIcons() });
+ ? new IMigration[] { new M0285_AddTypeIconsPg(), new M0286_AddPersonnelRoleIconsPg(), new M0289_AddTypeIconVariantsPg() }
+ : new IMigration[] { new M0285_AddTypeIcons(), new M0286_AddPersonnelRoleIcons(), new M0289_AddTypeIconVariants() });
_runner = new ServiceCollection().AddFluentMigratorCore().ConfigureRunner(r =>
{
if (IsPostgres) r.AddPostgres(); else r.AddSqlServer();
@@ -276,6 +279,163 @@ public async Task DeleteForTypeAsync_keeps_only_the_named_key_the_other_types_an
(await Repository().GetByIconKeyAsync(otherKind.IconKey)).Should().NotBeNull("the POI type with the same id is a different type");
}
+ private static TypeIcon NewVariant(TypeIconKinds kind, int typeId, string variant)
+ {
+ var icon = NewIcon(kind, typeId);
+ icon.Variant = variant;
+ return icon;
+ }
+
+ [Test]
+ public async Task DeleteSlotAsync_touches_only_its_own_slot()
+ {
+ var own = NewIcon(TypeIconKinds.UnitType, 31);
+ var oldResponding = NewVariant(TypeIconKinds.UnitType, 31, "s5");
+ var responding = NewVariant(TypeIconKinds.UnitType, 31, "s5");
+ var onScene = NewVariant(TypeIconKinds.UnitType, 31, "o44");
+ foreach (var icon in new[] { own, oldResponding, responding, onScene })
+ await Repository().InsertAsync(icon, CancellationToken.None);
+
+ (await Repository().DeleteSlotAsync(DepartmentId, TypeIconKinds.UnitType, 31, "s5", responding.IconKey)).Should().Be(1);
+ (await Repository().GetByIconKeyAsync(oldResponding.IconKey)).Should().BeNull();
+ (await Repository().GetByIconKeyAsync(responding.IconKey)).Should().NotBeNull();
+
+ (await Repository().DeleteSlotAsync(DepartmentId, TypeIconKinds.UnitType, 31, null)).Should().Be(1, "only the type's own icon is in the null slot");
+ (await Repository().GetByIconKeyAsync(own.IconKey)).Should().BeNull();
+ (await Repository().GetByIconKeyAsync(responding.IconKey)).Should().NotBeNull();
+ (await Repository().GetByIconKeyAsync(onScene.IconKey)).Should().NotBeNull();
+
+ (await Repository().DeleteForTypeAsync(DepartmentId, TypeIconKinds.UnitType, 31)).Should().Be(2, "the type delete removes every slot");
+ }
+
+ [Test]
+ public async Task DeleteAllButNewestInSlotAsync_keeps_the_newest_upload_of_only_its_own_slot()
+ {
+ var older = NewVariant(TypeIconKinds.UnitType, 31, "s5");
+ var newer = NewVariant(TypeIconKinds.UnitType, 31, "s5");
+ newer.UploadedOn = older.UploadedOn.AddSeconds(1);
+ var own = NewIcon(TypeIconKinds.UnitType, 31);
+ var otherSlot = NewVariant(TypeIconKinds.UnitType, 31, "o44");
+ var otherType = NewVariant(TypeIconKinds.UnitType, 32, "s5");
+ foreach (var icon in new[] { newer, older, own, otherSlot, otherType })
+ await Repository().InsertAsync(icon, CancellationToken.None);
+
+ // Two uploads at once: both rows are in before either upload's clean-up runs, and both run it.
+ (await Repository().DeleteAllButNewestInSlotAsync(DepartmentId, TypeIconKinds.UnitType, 31, "s5")).Should().Be(1);
+ (await Repository().DeleteAllButNewestInSlotAsync(DepartmentId, TypeIconKinds.UnitType, 31, "s5")).Should().Be(0, "the newest upload is never deleted");
+
+ (await Repository().GetByIconKeyAsync(older.IconKey)).Should().BeNull();
+ (await Repository().GetByIconKeyAsync(newer.IconKey)).Should().NotBeNull();
+ foreach (var kept in new[] { own, otherSlot, otherType })
+ (await Repository().GetByIconKeyAsync(kept.IconKey)).Should().NotBeNull();
+ }
+
+ [Test]
+ public async Task DeleteAllButNewestInSlotAsync_keeps_the_greater_key_of_uploads_made_at_the_same_time()
+ {
+ var first = NewVariant(TypeIconKinds.CallType, 31, "p2");
+ var second = NewVariant(TypeIconKinds.CallType, 31, "p2");
+ foreach (var icon in new[] { first, second })
+ await Repository().InsertAsync(icon, CancellationToken.None);
+ var greater = string.CompareOrdinal(first.IconKey, second.IconKey) > 0 ? first : second;
+ var lesser = greater == first ? second : first;
+
+ (await Repository().DeleteAllButNewestInSlotAsync(DepartmentId, TypeIconKinds.CallType, 31, "p2")).Should().Be(1);
+
+ (await Repository().GetByIconKeyAsync(greater.IconKey)).Should().NotBeNull();
+ (await Repository().GetByIconKeyAsync(lesser.IconKey)).Should().BeNull();
+ }
+
+ [TestCase(TypeIconKinds.UnitType, "UnitTypes", "Type")]
+ [TestCase(TypeIconKinds.CallType, "CallTypes", "Type")]
+ [TestCase(TypeIconKinds.PoiType, "POITypes", "Name")]
+ [TestCase(TypeIconKinds.PersonnelRole, "PersonnelRoles", "Name")]
+ public async Task SetTypeIconKeyToNewestAsync_points_only_the_departments_own_type_at_its_newest_own_icon(TypeIconKinds kind, string table, string nameColumn)
+ {
+ var own = await InsertTypeAsync(table, nameColumn, DepartmentId);
+ var foreign = await InsertTypeAsync(table, nameColumn, DepartmentId + 1);
+ var older = NewIcon(kind, own);
+ var newer = NewIcon(kind, own);
+ newer.UploadedOn = older.UploadedOn.AddSeconds(1);
+ // Newer still, but not the type's own icon slot, or not this type.
+ var variant = NewVariant(kind, own, "s5");
+ variant.UploadedOn = older.UploadedOn.AddSeconds(2);
+ var otherType = NewIcon(kind, own + 1000);
+ otherType.UploadedOn = older.UploadedOn.AddSeconds(2);
+ foreach (var icon in new[] { newer, older, variant, otherType })
+ await Repository().InsertAsync(icon, CancellationToken.None);
+
+ (await Repository().SetTypeIconKeyToNewestAsync(DepartmentId, kind, own)).Should().Be(1);
+ (await Repository().SetTypeIconKeyToNewestAsync(DepartmentId, kind, foreign)).Should().Be(0, "another department's type is never touched");
+
+ (await IconKeyOfAsync(table, own)).Should().Be(newer.IconKey);
+ (await IconKeyOfAsync(table, foreign)).Should().BeNull();
+ }
+
+ [Test]
+ public async Task SetTypeIconKeyToNewestAsync_clears_the_key_when_the_slot_is_empty()
+ {
+ var own = await InsertTypeAsync("UnitTypes", "Type", DepartmentId);
+ (await Repository().SetTypeIconKeyAsync(DepartmentId, TypeIconKinds.UnitType, own, "abc")).Should().Be(1);
+
+ (await Repository().SetTypeIconKeyToNewestAsync(DepartmentId, TypeIconKinds.UnitType, own)).Should().Be(1);
+
+ (await IconKeyOfAsync("UnitTypes", own)).Should().BeNull();
+ }
+
+ [Test]
+ public async Task Two_uploads_at_once_leave_the_type_on_the_one_icon_its_slot_keeps()
+ {
+ var type = await InsertTypeAsync("CallTypes", "Type", DepartmentId);
+ var first = NewIcon(TypeIconKinds.CallType, type);
+ var second = NewIcon(TypeIconKinds.CallType, type);
+ second.UploadedOn = first.UploadedOn.AddMilliseconds(5);
+
+ // Both rows go in before either upload goes on; then each points the type and cleans up, the first upload first.
+ // Pointing at "this upload" and keeping "this upload" would delete both rows here and leave the type on a dead key.
+ await Repository().InsertAsync(first, CancellationToken.None);
+ await Repository().InsertAsync(second, CancellationToken.None);
+ for (var upload = 0; upload < 2; upload++)
+ {
+ (await Repository().SetTypeIconKeyToNewestAsync(DepartmentId, TypeIconKinds.CallType, type)).Should().Be(1);
+ await Repository().DeleteAllButNewestInSlotAsync(DepartmentId, TypeIconKinds.CallType, type, null);
+ }
+
+ (await IconKeyOfAsync("CallTypes", type)).Should().Be(second.IconKey);
+ (await Repository().GetByIconKeyAsync(second.IconKey)).Should().NotBeNull();
+ (await Repository().GetByIconKeyAsync(first.IconKey)).Should().BeNull();
+ }
+
+ [Test]
+ public async Task GetVariantsForDepartmentAsync_lists_only_the_departments_status_and_priority_icons()
+ {
+ var own = NewIcon(TypeIconKinds.CallType, 31);
+ var high = NewVariant(TypeIconKinds.CallType, 31, "p2");
+ var foreign = NewVariant(TypeIconKinds.CallType, 31, "p3");
+ foreign.DepartmentId = DepartmentId + 1;
+ foreach (var icon in new[] { own, high, foreign })
+ await Repository().InsertAsync(icon, CancellationToken.None);
+
+ var variants = await Repository().GetVariantsForDepartmentAsync(DepartmentId);
+
+ variants.Should().ContainSingle();
+ variants[0].TypeKind.Should().Be((int)TypeIconKinds.CallType);
+ variants[0].TypeId.Should().Be(31);
+ variants[0].Variant.Should().Be("p2");
+ variants[0].IconKey.Should().Be(high.IconKey);
+ }
+
+ [TestCase(TypeIconKinds.UnitType, "UnitTypes", "Type")]
+ [TestCase(TypeIconKinds.PersonnelRole, "PersonnelRoles", "Name")]
+ public async Task TypeBelongsToDepartmentAsync_is_true_only_for_the_departments_own_type(TypeIconKinds kind, string table, string nameColumn)
+ {
+ var own = await InsertTypeAsync(table, nameColumn, DepartmentId);
+ var foreign = await InsertTypeAsync(table, nameColumn, DepartmentId + 1);
+
+ (await Repository().TypeBelongsToDepartmentAsync(DepartmentId, kind, own)).Should().BeTrue();
+ (await Repository().TypeBelongsToDepartmentAsync(DepartmentId, kind, foreign)).Should().BeFalse();
+ }
+
[Test]
public async Task The_icon_key_is_unique()
{
diff --git a/Tests/Resgrid.Tests/Services/CustomStateServiceResetTargetTests.cs b/Tests/Resgrid.Tests/Services/CustomStateServiceResetTargetTests.cs
index 99e1965a4..d06656299 100644
--- a/Tests/Resgrid.Tests/Services/CustomStateServiceResetTargetTests.cs
+++ b/Tests/Resgrid.Tests/Services/CustomStateServiceResetTargetTests.cs
@@ -14,13 +14,24 @@ namespace Resgrid.Tests.Services
/// The daily status and staffing resets (Department Settings) now save a meaning ("b{n}") and resolve it when they run,
/// so a department on its own statuses or levels resets to its option with that meaning. Values saved before meanings
/// keep working: one that is still the department's option is used as is, anything else (a built-in value after the
- /// department moved to its own set, or an option since deleted) follows its meaning, and nothing matching means skip.
+ /// department moved to its own set, or an option since deleted) follows its meaning. When no option has that meaning
+ /// (sets created before meanings read None on every option) an option without a meaning matched by name stands in, and
+ /// with none of those the value is written as it was before meanings; only a
+ /// value that is neither built-in nor the department's own option, or a meaning with no match, means skip.
///
[TestFixture]
public class CustomStateServiceResetTargetTests
{
private const int CustomDepartmentId = 14856;
private const int BuiltInDepartmentId = 2;
+ private const int LegacyDepartmentId = 2163;
+
+ private const int LegacyStatusId = 6101;
+ private const int DeletedLegacyStatusId = 6102;
+ private const int LegacyNotRespondingStatusId = 6103;
+ private const int LegacyLevelId = 6190;
+ private const int DeletedLegacyLevelId = 6191;
+ private const int LegacyWorkingLevelId = 6192;
private const int AvailableStatusId = 19721;
private const int EnRouteStatusId = 19722;
@@ -55,13 +66,28 @@ public void SetUp()
});
repository.Setup(x => x.GetCustomStatesByDepartmentIdAsync(BuiltInDepartmentId)).ReturnsAsync(new List());
+ // Sets created before meanings: every option reads None, so no meaning matches anything.
+ repository.Setup(x => x.GetCustomStatesByDepartmentIdAsync(LegacyDepartmentId)).ReturnsAsync(new List
+ {
+ Set(301, CustomStateTypes.Personnel, LegacyDepartmentId,
+ Option(LegacyStatusId, 301, "In Service", ActionBaseTypes.None),
+ Option(DeletedLegacyStatusId, 301, "Old", ActionBaseTypes.None, deleted: true),
+ Option(LegacyNotRespondingStatusId, 301, "Not Responding", ActionBaseTypes.None)),
+ Set(302, CustomStateTypes.Staffing, LegacyDepartmentId,
+ Option(LegacyLevelId, 302, "Duty Crew", StaffingBaseTypes.None),
+ Option(DeletedLegacyLevelId, 302, "Old", StaffingBaseTypes.None, deleted: true),
+ Option(LegacyWorkingLevelId, 302, "Working", StaffingBaseTypes.None))
+ });
+
_service = new CustomStateService(repository.Object, Mock.Of(), Mock.Of(), Mock.Of());
}
- private static CustomState Set(int id, CustomStateTypes type, params CustomStateDetail[] details) => new CustomState
+ private static CustomState Set(int id, CustomStateTypes type, params CustomStateDetail[] details) => Set(id, type, CustomDepartmentId, details);
+
+ private static CustomState Set(int id, CustomStateTypes type, int departmentId, params CustomStateDetail[] details) => new CustomState
{
CustomStateId = id,
- DepartmentId = CustomDepartmentId,
+ DepartmentId = departmentId,
Type = (int)type,
Name = type.ToString(),
Details = new List(details)
@@ -112,6 +138,32 @@ public async Task A_saved_status_since_deleted_follows_its_meaning()
(await _service.ResolveStatusResetAsync(CustomDepartmentId, DeletedUnavailableStatusId.ToString())).Should().Be(UnavailableStatusId);
}
+ [Test]
+ public async Task A_saved_status_with_no_status_of_its_meaning_follows_a_status_without_a_meaning_by_name()
+ {
+ // RESGRID-WEBJOBS-BT: "'0' matches none of department 2163's statuses; nobody was reset".
+ (await _service.ResolveStatusResetAsync(LegacyDepartmentId, ((int)ActionTypes.StandingBy).ToString())).Should().Be(LegacyStatusId,
+ "\"In Service\" suggests Available, Standing By's meaning");
+ (await _service.ResolveStatusResetAsync(LegacyDepartmentId, ((int)ActionTypes.NotResponding).ToString())).Should().Be(LegacyNotRespondingStatusId,
+ "the status with the built-in status's name");
+ }
+
+ [Test]
+ public async Task A_saved_status_with_no_status_of_its_meaning_or_name_is_written_as_before_meanings()
+ {
+ (await _service.ResolveStatusResetAsync(LegacyDepartmentId, ((int)ActionTypes.OnScene).ToString())).Should().Be((int)ActionTypes.OnScene);
+ (await _service.ResolveStatusResetAsync(CustomDepartmentId, ((int)ActionTypes.OnScene).ToString())).Should().Be((int)ActionTypes.OnScene,
+ "the department has no On Scene status, so the built-in one is written as before");
+ (await _service.ResolveStatusResetAsync(LegacyDepartmentId, DeletedLegacyStatusId.ToString())).Should().Be(DeletedLegacyStatusId);
+ }
+
+ [Test]
+ public async Task A_saved_status_that_is_neither_built_in_nor_the_departments_resolves_to_nothing()
+ {
+ (await _service.ResolveStatusResetAsync(CustomDepartmentId, "10898")).Should().BeNull();
+ (await _service.ResolveStatusResetAsync(CustomDepartmentId, LegacyStatusId.ToString())).Should().BeNull("another department's option");
+ }
+
[TestCase(null)]
[TestCase("")]
[TestCase("not a status")]
@@ -163,8 +215,6 @@ public async Task A_saved_level_that_is_still_the_departments_level_is_kept()
public async Task A_saved_built_in_level_on_a_custom_department_follows_its_meaning()
{
(await _service.ResolveStaffingResetAsync(CustomDepartmentId, ((int)UserStateTypes.Unavailable).ToString())).Should().Be(OffLevelId);
- (await _service.ResolveStaffingResetAsync(CustomDepartmentId, ((int)UserStateTypes.Delayed).ToString())).Should().BeNull(
- "the department has no Delayed level, so the reset is skipped rather than writing a level it does not use");
}
[Test]
@@ -173,6 +223,30 @@ public async Task A_saved_level_since_deleted_follows_its_meaning()
(await _service.ResolveStaffingResetAsync(CustomDepartmentId, DeletedOffLevelId.ToString())).Should().Be(OffLevelId);
}
+ [Test]
+ public async Task A_saved_level_with_no_level_of_its_meaning_follows_a_level_without_a_meaning_by_name()
+ {
+ (await _service.ResolveStaffingResetAsync(LegacyDepartmentId, ((int)UserStateTypes.OnShift).ToString())).Should().Be(LegacyLevelId,
+ "\"Duty Crew\" is the first level whose name suggests On Shift");
+ }
+
+ [Test]
+ public async Task A_saved_level_with_no_level_of_its_meaning_or_name_is_written_as_before_meanings()
+ {
+ // RESGRID-WEBJOBS-BV / C7: "'2' matches none of department 2163's staffing levels; nobody was reset".
+ (await _service.ResolveStaffingResetAsync(LegacyDepartmentId, ((int)UserStateTypes.Unavailable).ToString())).Should().Be((int)UserStateTypes.Unavailable);
+ (await _service.ResolveStaffingResetAsync(LegacyDepartmentId, ((int)UserStateTypes.Available).ToString())).Should().Be((int)UserStateTypes.Available);
+ (await _service.ResolveStaffingResetAsync(CustomDepartmentId, ((int)UserStateTypes.Delayed).ToString())).Should().Be((int)UserStateTypes.Delayed,
+ "the department has no Delayed level, so the built-in one is written as before");
+ (await _service.ResolveStaffingResetAsync(LegacyDepartmentId, DeletedLegacyLevelId.ToString())).Should().Be(DeletedLegacyLevelId);
+ }
+
+ [Test]
+ public async Task A_saved_level_that_is_neither_built_in_nor_the_departments_resolves_to_nothing()
+ {
+ (await _service.ResolveStaffingResetAsync(CustomDepartmentId, LegacyLevelId.ToString())).Should().BeNull("another department's level");
+ }
+
[TestCase(null)]
[TestCase("")]
[TestCase("off")]
diff --git a/Tests/Resgrid.Tests/Services/MapIconTests.cs b/Tests/Resgrid.Tests/Services/MapIconTests.cs
new file mode 100644
index 000000000..d46dd1974
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/MapIconTests.cs
@@ -0,0 +1,562 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Services;
+using Resgrid.Web.Areas.User.Models.Mapping;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace Resgrid.Tests.Services
+{
+ ///
+ /// Map icons that follow status and priority (M0289): the style groups and colours, the pin and icon-slot names, the
+ /// resolver every map, the legend and the icon pages share, the server-drawn styled pins and badges, and the legend rows.
+ ///
+ [TestFixture]
+ public class MapIconTests
+ {
+ private const int DepartmentId = 7;
+
+ #region Styles
+
+ [Test]
+ public void Every_status_meaning_but_none_has_a_style_and_every_style_a_colour()
+ {
+ foreach (var baseType in Enum.GetValues(typeof(ActionBaseTypes)).Cast())
+ {
+ if (baseType == ActionBaseTypes.None)
+ MapIconStyles.ForStatusBaseType((int)baseType).Should().BeNull();
+ else
+ MapIconStyles.ForStatusBaseType((int)baseType).Should().NotBeNull(baseType.ToString());
+ }
+
+ foreach (var style in MapIconStyles.StatusStyles.Concat(MapIconStyles.PriorityStyles))
+ {
+ MapIconStyles.ColorOf(style).Should().MatchRegex("^#[0-9A-F]{6}$", style);
+ MapIconStyles.IsStyle(style).Should().BeTrue();
+ }
+
+ MapIconStyles.StatusStyles.Should().OnlyContain(s => MapIconStyles.BaseTypesOf(s).Any(), "every status style groups at least one meaning");
+ MapIconStyles.ColorOf(MapIconStyles.Unknown).Should().MatchRegex("^#[0-9A-F]{6}$");
+ TypeIcons.TryParsePinName("person-unknown", out _, out _).Should().BeTrue();
+ MapIconStyles.IsStyle("purple").Should().BeFalse();
+ MapIconStyles.IsStyle(null).Should().BeFalse();
+ }
+
+ [Test]
+ public void Statuses_group_into_the_styles_a_dispatcher_reads()
+ {
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.InQuarters).Should().Be(MapIconStyles.Available);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.Enroute).Should().Be(MapIconStyles.Responding);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.AtPatient).Should().Be(MapIconStyles.OnScene);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.AtHospital).Should().Be(MapIconStyles.Transporting);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.Standby).Should().Be(MapIconStyles.Staging);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.OnBreak).Should().Be(MapIconStyles.Delayed);
+ MapIconStyles.ForStatusBaseType((int)ActionBaseTypes.NotResponding).Should().Be(MapIconStyles.Unavailable);
+ MapIconStyles.ForStatusBaseType(999).Should().BeNull();
+ }
+
+ [Test]
+ public void Priorities_map_by_their_meaning()
+ {
+ MapIconStyles.ForPriorityBaseType((int)CallPriority.Low).Should().Be(MapIconStyles.PriorityLow);
+ MapIconStyles.ForPriorityBaseType((int)CallPriority.Emergency).Should().Be(MapIconStyles.PriorityEmergency);
+ MapIconStyles.ForPriorityBaseType(-1).Should().BeNull();
+ }
+
+ #endregion Styles
+
+ #region Names and URLs
+
+ [Test]
+ public void Pin_names_round_trip_and_unknown_ones_are_refused()
+ {
+ TypeIcons.PinName("fire", MapIconStyles.PriorityHigh).Should().Be("fire-high");
+ TypeIcons.TryParsePinName("ambulance-responding", out var pinBase, out var style).Should().BeTrue();
+ pinBase.Should().Be("ambulance");
+ style.Should().Be("responding");
+
+ foreach (var bad in new[] { null, "", "ambulance", "-responding", "ambulance-", "ambulance-purple", "spaceship-responding", "../engine-responding", "ENGINE-responding" })
+ TypeIcons.TryParsePinName(bad, out _, out _).Should().BeFalse(bad ?? "null");
+ }
+
+ [Test]
+ public void Every_built_in_call_and_unit_icon_and_the_defaults_can_be_styled()
+ {
+ TypeIcons.PinBases.Should().Contain(new[] { TypeIcons.CallPinBase, TypeIcons.UnitPinBase, TypeIcons.PersonPinBase, "fire", "cartwo", "watercraft" });
+ TypeIcons.PinBases.Should().HaveCount(43);
+ TypeIcons.PinBases.Should().OnlyContain(b => MapIconPinRenderer.HasArtwork(b), "the renderer ships the artwork of every pin");
+ }
+
+ [Test]
+ public void Pin_and_badge_urls_carry_the_palette_version()
+ {
+ TypeIcons.ApiPinUrl("https://api.example.test/", "fire-high").Should().Be($"https://api.example.test/api/v4/TypeIcons/Pins/fire-high?v={MapIconStyles.Version}");
+ TypeIcons.WebPinUrl("person-onscene").Should().Be($"/TypeIcons/Pins/person-onscene?v={MapIconStyles.Version}");
+ TypeIcons.ApiPinUrl("https://api.example.test", null).Should().BeNull();
+
+ TypeIcons.ApiIconUrl("https://api.example.test", "abc", MapIconStyles.Responding).Should().Be($"https://api.example.test/api/v4/TypeIcons/abc?s=responding&v={MapIconStyles.Version}");
+ TypeIcons.ApiIconUrl("https://api.example.test", "abc", null).Should().Be("https://api.example.test/api/v4/TypeIcons/abc", "no style, no badge");
+ TypeIcons.WebIconUrl("abc", "nonsense").Should().Be("/TypeIcons/abc", "an unknown style is never put in a URL");
+ TypeIcons.WebIconUrl(null, MapIconStyles.Responding).Should().BeNull();
+ }
+
+ [TestCase(TypeIconKinds.CallType, "p0", true)]
+ [TestCase(TypeIconKinds.CallType, "p4410", true)]
+ [TestCase(TypeIconKinds.CallType, "s1", false)]
+ [TestCase(TypeIconKinds.UnitType, "s0", true)]
+ [TestCase(TypeIconKinds.UnitType, "o12", true)]
+ [TestCase(TypeIconKinds.UnitType, "o0", false)]
+ [TestCase(TypeIconKinds.UnitType, "p1", false)]
+ [TestCase(TypeIconKinds.PersonnelRole, "s7", true)]
+ [TestCase(TypeIconKinds.PersonnelRole, "o3", true)]
+ [TestCase(TypeIconKinds.PoiType, "s1", false)]
+ [TestCase(TypeIconKinds.UnitType, "s01", false)]
+ [TestCase(TypeIconKinds.UnitType, "s1234567890", false)]
+ [TestCase(TypeIconKinds.UnitType, "s-1", false)]
+ [TestCase(TypeIconKinds.UnitType, "", false)]
+ public void Icon_slots_are_valid_only_for_their_kind(TypeIconKinds kind, string variant, bool valid)
+ {
+ TypeIcons.IsValidVariant(kind, variant).Should().Be(valid);
+ }
+
+ #endregion Names and URLs
+
+ #region Resolver
+
+ private static readonly CallType FireType = new CallType { CallTypeId = 11, DepartmentId = DepartmentId, Type = "Structure Fire", MapIconType = (int)MapIconTypes.Fire };
+ private static readonly CallType MedicalType = new CallType { CallTypeId = 12, DepartmentId = DepartmentId, Type = "Medical", IconKey = "medical-own" };
+ private static readonly UnitType AmbulanceType = new UnitType { UnitTypeId = 21, DepartmentId = DepartmentId, Type = "Ambulance", MapIconType = (int)MapIconTypes.Ambulance, CustomStatesId = 500 };
+ private static readonly UnitType MugType = new UnitType { UnitTypeId = 22, DepartmentId = DepartmentId, Type = "MUG", IconKey = "mug-own" };
+
+ private static List CustomStates() => new List
+ {
+ new CustomState
+ {
+ CustomStateId = 500, DepartmentId = DepartmentId, Type = (int)CustomStateTypes.Unit, Name = "Ambulance statuses",
+ Details = new List
+ {
+ new CustomStateDetail { CustomStateDetailId = 5001, CustomStateId = 500, ButtonText = "Free", BaseType = (int)ActionBaseTypes.Available },
+ new CustomStateDetail { CustomStateDetailId = 5002, CustomStateId = 500, ButtonText = "To hospital", BaseType = (int)ActionBaseTypes.Transporting },
+ new CustomStateDetail { CustomStateDetailId = 5003, CustomStateId = 500, ButtonText = "Spare", BaseType = (int)ActionBaseTypes.None }
+ }
+ },
+ new CustomState
+ {
+ CustomStateId = 600, DepartmentId = DepartmentId, Type = (int)CustomStateTypes.Personnel, Name = "People",
+ Details = new List
+ {
+ new CustomStateDetail { CustomStateDetailId = 6001, CustomStateId = 600, ButtonText = "Ready", BaseType = (int)ActionBaseTypes.Available },
+ new CustomStateDetail { CustomStateDetailId = 6002, CustomStateId = 600, ButtonText = "En route", BaseType = (int)ActionBaseTypes.Responding }
+ }
+ }
+ };
+
+ private static MapIconResolver Resolver(IEnumerable priorities = null, IEnumerable roles = null,
+ IEnumerable variants = null, CustomStateDetail defaultStatus = null, IEnumerable unitStates = null) =>
+ new MapIconResolver(DepartmentId, new[] { FireType, MedicalType }, priorities, new[] { AmbulanceType, MugType }, CustomStates(), roles, variants, defaultStatus, unitStates);
+
+ [Test]
+ public void A_call_takes_its_types_pin_in_the_style_of_its_built_in_priority()
+ {
+ var icon = Resolver().ForCall(new Call { CallTypeId = FireType.CallTypeId, Type = FireType.Type, Priority = (int)CallPriority.High });
+
+ icon.ImagePath.Should().Be("fire");
+ icon.Style.Should().Be(MapIconStyles.PriorityHigh);
+ icon.PinName.Should().Be("fire-high");
+ icon.Variant.Should().Be("p2");
+ icon.IconKey.Should().BeNull();
+ }
+
+ [Test]
+ public void A_call_with_no_type_takes_the_default_call_pin()
+ {
+ var icon = Resolver().ForCall(new Call { Priority = (int)CallPriority.Emergency });
+
+ icon.ImagePath.Should().Be(TypeIcons.LegacyCallImage);
+ icon.PinName.Should().Be("call-emergency");
+ }
+
+ [Test]
+ public void A_departments_own_priority_uses_its_meaning_and_one_without_keeps_the_plain_pin()
+ {
+ var priorities = new[]
+ {
+ new DepartmentCallPriority { DepartmentCallPriorityId = 901, DepartmentId = DepartmentId, Name = "P1", BaseType = (int)CallPriority.Emergency },
+ new DepartmentCallPriority { DepartmentCallPriorityId = 902, DepartmentId = DepartmentId, Name = "Info", BaseType = -1 },
+ // The oldest departments own ids 0 to 3; their own row wins over the built-in of the same value.
+ new DepartmentCallPriority { DepartmentCallPriorityId = 2, DepartmentId = DepartmentId, Name = "Routine", BaseType = (int)CallPriority.Low }
+ };
+ var resolver = Resolver(priorities);
+
+ resolver.ForCallType(FireType, 901).PinName.Should().Be("fire-emergency");
+ resolver.ForCallType(FireType, 902).PinName.Should().Be("fire-unknown", "a priority with no meaning, whose name suggests none, is gray");
+ resolver.ForCallType(FireType, 902).ImagePath.Should().Be("fire");
+ resolver.ForCallType(FireType, 2).Style.Should().Be(MapIconStyles.PriorityLow);
+ resolver.ForCallType(FireType, 3).Style.Should().Be(MapIconStyles.PriorityEmergency, "an old call keeps the built-in priority it was given");
+ resolver.ForCallType(FireType, 7777).Style.Should().Be(MapIconStyles.Unknown);
+ }
+
+ [Test]
+ public void An_uploaded_priority_icon_wins_and_the_types_own_icon_carries_a_badge_otherwise()
+ {
+ var resolver = Resolver(variants: new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.CallType, TypeId = MedicalType.CallTypeId, Variant = "p3", IconKey = "medical-emergency" } });
+
+ var emergency = resolver.ForCallType(MedicalType, (int)CallPriority.Emergency);
+ emergency.IconKey.Should().Be("medical-emergency");
+ emergency.IconIsVariant.Should().BeTrue();
+ emergency.BadgeStyle.Should().BeNull("an icon made for the priority is shown as uploaded");
+
+ var low = resolver.ForCallType(MedicalType, (int)CallPriority.Low);
+ low.IconKey.Should().Be("medical-own");
+ low.IconIsVariant.Should().BeFalse();
+ low.BadgeStyle.Should().Be(MapIconStyles.PriorityLow);
+ low.PinName.Should().Be("call-low", "the styled pin stays the fallback for clients that cannot load the upload");
+
+ resolver.ForCallType(MedicalType, null).BadgeStyle.Should().BeNull("no priority, no badge");
+ resolver.ForCallType(MedicalType, 7777).BadgeStyle.Should().BeNull("an unknown priority leaves the upload as it is");
+ resolver.ForCallType(MedicalType, 7777).PinName.Should().Be("call-unknown");
+ }
+
+ [Test]
+ public void A_unit_follows_its_custom_status_options_meaning()
+ {
+ var resolver = Resolver();
+
+ var transporting = resolver.ForUnit(new Unit { Type = "Ambulance" }, new UnitState { State = 5002 });
+ transporting.ImagePath.Should().Be("ambulance");
+ transporting.Variant.Should().Be("o5002");
+ transporting.PinName.Should().Be("ambulance-transporting");
+
+ resolver.ForUnit(new Unit { Type = "Ambulance" }, new UnitState { State = 5003 }).PinName.Should().Be("ambulance-unknown", "an option with no meaning and a name that suggests none is gray");
+ }
+
+ [Test]
+ public void An_option_or_priority_with_no_meaning_set_is_coloured_by_what_its_name_suggests()
+ {
+ var states = CustomStates();
+ states[0].Details.Add(new CustomStateDetail { CustomStateDetailId = 5004, CustomStateId = 500, ButtonText = "Out of Service", BaseType = (int)ActionBaseTypes.None });
+ states[1].Details.Add(new CustomStateDetail { CustomStateDetailId = 6003, CustomStateId = 600, ButtonText = "On Scene", BaseType = (int)ActionBaseTypes.None });
+ // The oldest departments own priorities 0 to 3 named like the built-ins, with no meaning set.
+ var priorities = new[] { new DepartmentCallPriority { DepartmentCallPriorityId = 3, DepartmentId = DepartmentId, Name = "Emergency", BaseType = -1 } };
+ var resolver = new MapIconResolver(DepartmentId, new[] { FireType }, priorities, new[] { AmbulanceType }, states, null, null);
+
+ resolver.ForUnit(new Unit { Type = "Ambulance" }, new UnitState { State = 5004 }).Style.Should().Be(MapIconStyles.Unavailable);
+ resolver.ForPerson(null, new ActionLog { ActionTypeId = 6003 }).Style.Should().Be(MapIconStyles.OnScene);
+ resolver.ForCallType(FireType, 3).PinName.Should().Be("fire-emergency");
+ }
+
+ [Test]
+ public void A_unit_in_a_built_in_status_uses_the_built_in_meaning_and_one_with_no_type_the_default_pin()
+ {
+ var icon = Resolver().ForUnit(new Unit { Type = "Unknown type" }, new UnitState { State = (int)UnitStateTypes.OnScene });
+
+ icon.ImagePath.Should().Be(TypeIcons.LegacyUnitImage);
+ icon.Variant.Should().Be("s6");
+ icon.PinName.Should().Be("engine-onscene");
+
+ Resolver().ForUnit(new Unit { Type = "Ambulance" }, null).PinName.Should().Be("ambulance-unknown", "a unit that never set a status is gray, never the red default");
+ Resolver().ForUnitType(AmbulanceType, null).Style.Should().BeNull("the type's own icon, with no status, has no style");
+ Resolver().ForUnitType(null, (int)UnitStateTypes.OutOfService).PinName.Should().Be("engine-unavailable");
+ }
+
+ [Test]
+ public void An_uploaded_status_icon_replaces_the_unit_types_own_icon_for_that_status_only()
+ {
+ var resolver = Resolver(variants: new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.UnitType, TypeId = MugType.UnitTypeId, Variant = "s5", IconKey = "mug-responding" } });
+
+ resolver.ForUnitType(MugType, (int)UnitStateTypes.Responding).IconKey.Should().Be("mug-responding");
+ var available = resolver.ForUnitType(MugType, (int)UnitStateTypes.Available);
+ available.IconKey.Should().Be("mug-own");
+ available.BadgeStyle.Should().Be(MapIconStyles.Available);
+ resolver.VariantIconKey(TypeIconKinds.UnitType, MugType.UnitTypeId, "s5").Should().Be("mug-responding");
+ resolver.VariantIconKey(TypeIconKinds.UnitType, AmbulanceType.UnitTypeId, "s5").Should().BeNull();
+ }
+
+ [Test]
+ public void A_member_takes_the_icon_of_their_first_role_in_order_with_one_for_the_status_or_of_its_own()
+ {
+ var officer = new PersonnelRole { PersonnelRoleId = 1, DepartmentId = DepartmentId, Name = "Officer", SortOrder = 1 };
+ var paramedic = new PersonnelRole { PersonnelRoleId = 2, DepartmentId = DepartmentId, Name = "Paramedic", SortOrder = 2, IconKey = "paramedic-own" };
+ var firefighter = new PersonnelRole { PersonnelRoleId = 3, DepartmentId = DepartmentId, Name = "Firefighter", SortOrder = 3, IconKey = "firefighter-own" };
+ var variants = new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.PersonnelRole, TypeId = officer.PersonnelRoleId, Variant = "o6002", IconKey = "officer-enroute" } };
+ var resolver = Resolver(roles: new[] { officer, paramedic, firefighter }, variants: variants);
+
+ var enRoute = resolver.ForPerson(new List { 3, 1, 2 }, new ActionLog { ActionTypeId = 6002 });
+ enRoute.IconKey.Should().Be("officer-enroute", "the officer role comes first and has an icon for this status");
+ enRoute.Style.Should().Be(MapIconStyles.Responding);
+
+ var ready = resolver.ForPerson(new List { 3, 1, 2 }, new ActionLog { ActionTypeId = 6001 });
+ ready.IconKey.Should().Be("paramedic-own", "the officer role has no icon for Ready and none of its own");
+ ready.BadgeStyle.Should().Be(MapIconStyles.Available);
+
+ var noRoleIcon = resolver.ForPerson(new List(), new ActionLog { ActionTypeId = (int)ActionTypes.Responding });
+ noRoleIcon.IconKey.Should().BeNull();
+ noRoleIcon.ImagePath.Should().Be(TypeIcons.LegacyPersonImage);
+ noRoleIcon.PinName.Should().Be("person-responding");
+ }
+
+ [Test]
+ public void A_member_On_Unit_takes_the_style_of_the_units_latest_status()
+ {
+ var unitStates = new[] { new UnitState { UnitStateId = 70, UnitId = 4, State = 5002 } };
+ var resolver = Resolver(unitStates: unitStates);
+
+ var onUnit = resolver.ForPerson(null, new ActionLog { ActionTypeId = (int)ActionTypes.OnUnit, UnitStateId = 70 });
+ onUnit.Variant.Should().Be("s7");
+ onUnit.Style.Should().Be(MapIconStyles.Transporting);
+
+ resolver.ForPerson(null, new ActionLog { ActionTypeId = (int)ActionTypes.OnUnit, UnitStateId = 71 }).Style.Should().Be(MapIconStyles.Unknown, "the unit has moved on since");
+ }
+
+ [Test]
+ public void A_member_with_no_status_shows_the_departments_default_status()
+ {
+ Resolver(defaultStatus: new CustomStateDetail { CustomStateDetailId = 6001, BaseType = (int)ActionBaseTypes.Available })
+ .ForPerson(null, null).Variant.Should().Be("o6001");
+ Resolver(defaultStatus: new CustomStateDetail { CustomStateDetailId = 0, BaseType = (int)ActionBaseTypes.Available })
+ .ForPerson(null, null).PinName.Should().Be("person-available");
+ Resolver().ForPerson(null, null).Style.Should().Be(MapIconStyles.Unknown);
+ }
+
+ #endregion Resolver
+
+ #region Renderer
+
+ [Test]
+ public void A_styled_pin_keeps_the_artworks_size_and_takes_the_styles_colour()
+ {
+ var data = MapIconPinRenderer.RenderPin("ambulance-responding");
+
+ data.Should().NotBeNull();
+ using var image = Image.Load(data);
+ image.Width.Should().Be(32);
+ image.Height.Should().Be(37);
+
+ var target = Hex(MapIconStyles.ColorOf(MapIconStyles.Responding));
+ var body = MostCommonOpaqueColour(image);
+ Math.Abs(body.R - target.R).Should().BeLessThan(4);
+ Math.Abs(body.G - target.G).Should().BeLessThan(4);
+ Math.Abs(body.B - target.B).Should().BeLessThan(4);
+ image[16, 36].A.Should().BeGreaterThan(0, "the bottom tip, the marker's anchor, is still drawn");
+
+ MapIconPinRenderer.RenderPin("ambulance-responding").Should().BeSameAs(data, "pins are drawn once per process");
+ MapIconPinRenderer.RenderPin("ambulance-purple").Should().BeNull();
+ MapIconPinRenderer.RenderPin("../../secrets-responding").Should().BeNull();
+ }
+
+ [Test]
+ public void A_badge_is_drawn_in_the_lower_right_corner_and_leaves_the_rest_of_the_icon()
+ {
+ byte[] icon;
+ using (var source = new Image(128, 128, new Rgba32(0, 0, 0, 0)))
+ using (var stream = new MemoryStream())
+ {
+ source[20, 20] = new Rgba32(10, 200, 10, 255);
+ source.Save(stream, new PngEncoder());
+ icon = stream.ToArray();
+ }
+
+ var data = MapIconPinRenderer.RenderBadge(icon, MapIconStyles.Unavailable, "checksum-a");
+
+ using var image = Image.Load(data);
+ image.Width.Should().Be(128);
+ image[20, 20].Should().Be(new Rgba32(10, 200, 10, 255));
+ var centre = (int)(128 - 128 * 0.19 - 1);
+ image[centre, centre].Should().Be(Hex(MapIconStyles.ColorOf(MapIconStyles.Unavailable)));
+ MapIconPinRenderer.RenderBadge(icon, "nonsense", "checksum-a").Should().BeNull();
+ MapIconPinRenderer.RenderBadge(new byte[] { 1, 2, 3 }, MapIconStyles.Available, "bad").Should().BeNull();
+ }
+
+ private static Rgba32 Hex(string hex) => new Rgba32(Convert.ToByte(hex.Substring(1, 2), 16), Convert.ToByte(hex.Substring(3, 2), 16), Convert.ToByte(hex.Substring(5, 2), 16), 255);
+
+ private static Rgba32 MostCommonOpaqueColour(Image image)
+ {
+ var counts = new Dictionary();
+ for (var y = 0; y < image.Height; y++)
+ for (var x = 0; x < image.Width; x++)
+ {
+ var pixel = image[x, y];
+ if (pixel.A == 255 && Math.Min(pixel.R, Math.Min(pixel.G, pixel.B)) < 200)
+ counts[pixel] = counts.TryGetValue(pixel, out var count) ? count + 1 : 1;
+ }
+
+ return counts.OrderByDescending(x => x.Value).First().Key;
+ }
+
+ #endregion Renderer
+
+ #region Legend
+
+ [Test]
+ public void A_unit_types_legend_row_lists_its_own_status_set_else_the_built_in_statuses()
+ {
+ var builtIns = new List { new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available", ButtonColor = "#d1dade" } };
+
+ var ambulance = MapIconLegendBuilder.UnitRow(Resolver(), AmbulanceType, null, CustomStates(), builtIns);
+ ambulance.StatusSetName.Should().Be("Ambulance statuses");
+ ambulance.States.Select(s => s.Name).Should().Equal("Free", "To hospital", "Spare");
+ MapLegendIcon.From(ambulance.States[1].Icon).Url.Should().Be(TypeIcons.WebPinUrl("ambulance-transporting"));
+ MapLegendIcon.From(ambulance.States[2].Icon).Url.Should().Be(TypeIcons.WebPinUrl("ambulance-unknown"), "no meaning and no suggestion, gray pin");
+ ambulance.OwnIcon.ImagePath.Should().Be("ambulance", "the built-in icon the type picked");
+
+ var mug = MapIconLegendBuilder.UnitRow(Resolver(), MugType, null, CustomStates(), builtIns);
+ mug.StatusSetName.Should().BeNull();
+ MapLegendIcon.From(mug.States.Single().Icon).Url.Should().Be(TypeIcons.WebIconUrl("mug-own", MapIconStyles.Available));
+ MapLegendIcon.From(mug.States.Single().Icon).IsUploaded.Should().BeTrue();
+ mug.States.Single().Color.Should().Be("#d1dade");
+ }
+
+ [Test]
+ public void The_icon_page_shows_what_each_status_falls_back_to_without_an_upload()
+ {
+ var resolver = Resolver(variants: new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.UnitType, TypeId = MugType.UnitTypeId, Variant = "s5", IconKey = "mug-responding" } });
+ var builtIns = new List
+ {
+ new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Responding, ButtonText = "Responding" },
+ new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available" }
+ };
+ var row = MapIconLegendBuilder.UnitRow(resolver, MugType, null, CustomStates(), builtIns);
+
+ var rows = StatusIconRow.ForRow(resolver, TypeIconKinds.UnitType, MugType.UnitTypeId, row);
+
+ rows[0].UploadedUrl.Should().Be(TypeIcons.WebIconUrl("mug-responding"));
+ rows[0].DefaultIcon.Url.Should().Be(TypeIcons.WebIconUrl("mug-own", MapIconStyles.Responding));
+ rows[1].UploadedUrl.Should().BeNull();
+ MapLegendIcon.From(rows[1].State.Icon).Url.Should().Be(TypeIcons.WebIconUrl("mug-own", MapIconStyles.Available));
+ }
+
+ private static readonly List OwnPriorities = new List
+ {
+ new DepartmentCallPriority { DepartmentCallPriorityId = 901, DepartmentId = DepartmentId, Name = "P1 Life threat", BaseType = (int)CallPriority.Emergency, Color = "#ff0000" },
+ new DepartmentCallPriority { DepartmentCallPriorityId = 902, DepartmentId = DepartmentId, Name = "Zebra", BaseType = -1 }
+ };
+
+ private static readonly List BuiltInUnitStatuses = new List
+ {
+ new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Available, ButtonText = "Available" },
+ new CustomStateDetail { CustomStateDetailId = (int)UnitStateTypes.Responding, ButtonText = "Responding" }
+ };
+
+ private static MapIconLegend Legend(MapIconResolver resolver, IEnumerable roles = null) =>
+ MapIconLegendBuilder.Build(resolver, OwnPriorities, CustomStates(), BuiltInUnitStatuses, CustomStates()[1].Details, roles,
+ "No call type", "No unit type", "No role icon");
+
+ [Test]
+ public void The_legend_is_named_and_drawn_from_the_departments_own_types_priorities_statuses_and_roles()
+ {
+ var withIcon = new PersonnelRole { PersonnelRoleId = 31, DepartmentId = DepartmentId, Name = "Paramedic", IconKey = "medic-own", SortOrder = 2 };
+ var withStatusIconOnly = new PersonnelRole { PersonnelRoleId = 32, DepartmentId = DepartmentId, Name = "Officer", SortOrder = 1 };
+ var plain = new PersonnelRole { PersonnelRoleId = 33, DepartmentId = DepartmentId, Name = "Firefighter" };
+ var roles = new[] { withIcon, withStatusIconOnly, plain };
+ var resolver = Resolver(OwnPriorities, roles, new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.PersonnelRole, TypeId = 32, Variant = "o6002", IconKey = "officer-en-route" } });
+
+ var legend = Legend(resolver, roles);
+
+ legend.CallRows.Select(x => x.Name).Should().Equal("No call type", "Medical", "Structure Fire");
+ legend.CallRows[0].Id.Should().BeNull();
+ var fire = legend.CallRows[2];
+ fire.OwnIcon.ImagePath.Should().Be("fire");
+ fire.States.Select(x => x.Name).Should().Equal("P1 Life threat", "Zebra");
+ fire.States.Select(x => x.Icon.PinName).Should().Equal("fire-emergency", "fire-unknown");
+ fire.States[0].Color.Should().Be("#ff0000");
+ legend.CallRows[1].States[0].Icon.IconKey.Should().Be("medical-own");
+ legend.CallRows[1].States[0].Icon.BadgeStyle.Should().Be(MapIconStyles.PriorityEmergency);
+
+ legend.UnitRows.Select(x => x.Name).Should().Equal("No unit type", "Ambulance", "MUG");
+ legend.UnitRows[1].StatusSetName.Should().Be("Ambulance statuses");
+ legend.UnitRows[1].States.Select(x => x.Name).Should().Equal("Free", "To hospital", "Spare");
+ legend.UnitRows[0].States.Select(x => x.Icon.PinName).Should().Equal("engine-available", "engine-responding");
+
+ legend.PersonnelRows.Select(x => x.Name).Should().Equal(new[] { "No role icon", "Officer", "Paramedic" }, "only roles with an icon of any kind, in role order");
+ legend.PersonnelRows.Select(x => x.States.Count).Should().OnlyContain(c => c == 2);
+ legend.PersonnelRows[1].States[1].Icon.IconKey.Should().Be("officer-en-route");
+ legend.PersonnelRows[1].States[1].Icon.IconIsVariant.Should().BeTrue();
+ legend.PersonnelRows[2].States[0].Icon.IconKey.Should().Be("medic-own");
+ }
+
+ [Test]
+ public void The_colour_key_lists_the_departments_own_statuses_and_priorities_in_each_style()
+ {
+ var legend = Legend(Resolver(OwnPriorities));
+
+ legend.StatusStyles.Select(x => x.Key).Should().Equal(MapIconStyles.StatusStyles.Concat(new[] { MapIconStyles.Unknown }));
+ var available = legend.StatusStyles.Single(x => x.Key == MapIconStyles.Available);
+ available.UnitStatusNames.Should().Equal("Available", "Free");
+ available.PersonnelStatusNames.Should().Equal("Ready");
+ available.Meanings.Should().Contain(ActionBaseTypes.InQuarters);
+ legend.StatusStyles.Single(x => x.Key == MapIconStyles.Responding).PersonnelStatusNames.Should().Equal("En route");
+ legend.StatusStyles.Single(x => x.Key == MapIconStyles.Transporting).UnitStatusNames.Should().Equal("To hospital");
+ var unknown = legend.StatusStyles.Last();
+ unknown.UnitStatusNames.Should().Equal("Spare");
+ unknown.Meanings.Should().BeEmpty();
+
+ legend.PriorityStyles.Select(x => x.Key).Should().Equal(MapIconStyles.PriorityStyles.Concat(new[] { MapIconStyles.Unknown }));
+ legend.PriorityStyles.Single(x => x.Key == MapIconStyles.PriorityEmergency).PriorityNames.Should().Equal("P1 Life threat");
+ legend.PriorityStyles.Last().PriorityNames.Should().Equal("Zebra");
+ legend.PriorityStyles.Single(x => x.Key == MapIconStyles.PriorityLow).PriorityNames.Should().BeEmpty();
+ }
+
+ [Test]
+ public void On_unit_follows_its_units_status_so_it_is_never_listed_under_gray()
+ {
+ var builtInPersonnel = new List
+ {
+ new CustomStateDetail { CustomStateDetailId = (int)ActionTypes.StandingBy, ButtonText = "Standing By" },
+ new CustomStateDetail { CustomStateDetailId = (int)ActionTypes.OnUnit, ButtonText = "On Unit" }
+ };
+
+ var legend = MapIconLegendBuilder.Build(Resolver(), OwnPriorities, CustomStates(), BuiltInUnitStatuses, builtInPersonnel, null, "c", "u", "r");
+
+ var states = legend.PersonnelRows.Single().States;
+ states.Select(x => x.FollowsUnitStatus).Should().Equal(false, true);
+ states[1].Style.Should().Be(MapIconStyles.Unknown, "with no unit to follow the pin is gray");
+ legend.StatusStyles.SelectMany(x => x.PersonnelStatusNames).Should().NotContain("On Unit");
+ legend.StatusStyles.Single(x => x.Key == MapIconStyles.Available).PersonnelStatusNames.Should().Equal("Standing By");
+ }
+
+ [Test]
+ public void A_renamed_type_another_built_in_icon_and_a_new_upload_all_show_in_the_legend()
+ {
+ var renamed = new CallType { CallTypeId = FireType.CallTypeId, DepartmentId = DepartmentId, Type = "Working fire", MapIconType = (int)MapIconTypes.Ambulance };
+ var resolver = new MapIconResolver(DepartmentId, new[] { renamed }, OwnPriorities, new[] { AmbulanceType }, CustomStates(), null,
+ new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.CallType, TypeId = renamed.CallTypeId, Variant = "p902", IconKey = "fire-whenever" } });
+
+ var row = Legend(resolver).CallRows.Single(x => x.Id == renamed.CallTypeId);
+
+ row.Name.Should().Be("Working fire");
+ row.OwnIcon.ImagePath.Should().Be("ambulance");
+ row.States[0].Icon.PinName.Should().Be("ambulance-emergency");
+ row.States[1].Icon.IconKey.Should().Be("fire-whenever");
+ row.States[1].Icon.BadgeStyle.Should().BeNull("an upload for the very priority carries no badge");
+ }
+
+ [Test]
+ public void The_resolver_knows_which_types_have_status_or_priority_icons()
+ {
+ var resolver = Resolver(variants: new[] { new TypeIconVariant { TypeKind = (int)TypeIconKinds.UnitType, TypeId = MugType.UnitTypeId, Variant = "s5", IconKey = "mug-responding" } });
+
+ resolver.HasVariantIcons(TypeIconKinds.UnitType, MugType.UnitTypeId).Should().BeTrue();
+ resolver.HasVariantIcons(TypeIconKinds.UnitType, AmbulanceType.UnitTypeId).Should().BeFalse();
+ resolver.HasVariantIcons(TypeIconKinds.PersonnelRole, MugType.UnitTypeId).Should().BeFalse("kinds never mix");
+ }
+
+ [TestCase("#fff", "#fff")]
+ [TestCase(" #12ab9F ", "#12ab9F")]
+ [TestCase("red", null)]
+ [TestCase("#fff;background:url(x)", null)]
+ [TestCase("label-default", null)]
+ [TestCase(null, null)]
+ public void Only_plain_hex_colours_reach_a_style_attribute(string color, string expected)
+ {
+ MapIconLegendBuilder.HexOrNull(color).Should().Be(expected);
+ }
+
+ #endregion Legend
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/TypeIconTests.cs b/Tests/Resgrid.Tests/Services/TypeIconTests.cs
index 037d86263..f03c87522 100644
--- a/Tests/Resgrid.Tests/Services/TypeIconTests.cs
+++ b/Tests/Resgrid.Tests/Services/TypeIconTests.cs
@@ -159,9 +159,9 @@ public async Task UploadIconAsync_stores_a_new_icon_and_points_the_type_at_it(Ty
TypeIcon inserted = null;
repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.Callback((TypeIcon i, CancellationToken _, bool __) => inserted = i).ReturnsAsync((TypeIcon i, CancellationToken _, bool __) => i);
- repository.Setup(x => x.SetTypeIconKeyAsync(DepartmentId, kind, TypeId, It.IsAny(), It.IsAny())).ReturnsAsync(1);
+ repository.Setup(x => x.SetTypeIconKeyToNewestAsync(DepartmentId, kind, TypeId, It.IsAny())).ReturnsAsync(1);
- var key = await new TypeIconsService(repository.Object).UploadIconAsync(DepartmentId, kind, TypeId, "admin", Png(48, 48, new Rgba32(0, 120, 0, 255)));
+ var key = await Service(repository.Object).UploadIconAsync(DepartmentId, kind, TypeId, "admin", Png(48, 48, new Rgba32(0, 120, 0, 255)));
inserted.Should().NotBeNull();
inserted.DepartmentId.Should().Be(DepartmentId);
@@ -171,21 +171,27 @@ public async Task UploadIconAsync_stores_a_new_icon_and_points_the_type_at_it(Ty
inserted.Checksum.Should().HaveLength(64);
inserted.Data.Length.Should().Be((int)inserted.ByteSize);
key.Should().Be(inserted.IconKey, "a new upload gets a new key, so the cached URL never serves the old image");
- repository.Verify(x => x.SetTypeIconKeyAsync(DepartmentId, kind, TypeId, inserted.IconKey, It.IsAny()), Times.Once);
- repository.Verify(x => x.DeleteForTypeAsync(DepartmentId, kind, TypeId, inserted.IconKey, It.IsAny()), Times.Once);
+ repository.Verify(x => x.SetTypeIconKeyToNewestAsync(DepartmentId, kind, TypeId, It.IsAny()), Times.Once);
+ repository.Verify(x => x.DeleteAllButNewestInSlotAsync(DepartmentId, kind, TypeId, null, It.IsAny()), Times.Once,
+ "only the type's own icon slot is replaced; its status and priority icons stay");
+ repository.Verify(x => x.SetTypeIconKeyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never,
+ "pointing the type at \"this upload\" would let two uploads at once leave it on a deleted row");
+ repository.Verify(x => x.DeleteSlotAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ repository.Verify(x => x.DeleteForTypeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Test]
public async Task UploadIconAsync_for_another_departments_type_stores_nothing()
{
var repository = new Mock();
- repository.Setup(x => x.SetTypeIconKeyAsync(DepartmentId, TypeIconKinds.CallType, TypeId, It.IsAny(), It.IsAny())).ReturnsAsync(0);
+ repository.Setup(x => x.SetTypeIconKeyToNewestAsync(DepartmentId, TypeIconKinds.CallType, TypeId, It.IsAny())).ReturnsAsync(0);
- var key = await new TypeIconsService(repository.Object).UploadIconAsync(DepartmentId, TypeIconKinds.CallType, TypeId, "admin", Png(48, 48, new Rgba32(0, 0, 0, 255)));
+ var key = await Service(repository.Object).UploadIconAsync(DepartmentId, TypeIconKinds.CallType, TypeId, "admin", Png(48, 48, new Rgba32(0, 0, 0, 255)));
key.Should().BeNull("the department-scoped update changed no row, so the type is not this department's");
- repository.Verify(x => x.DeleteForTypeAsync(DepartmentId, TypeIconKinds.CallType, TypeId, null, It.IsAny()), Times.Once,
+ repository.Verify(x => x.DeleteSlotAsync(DepartmentId, TypeIconKinds.CallType, TypeId, null, null, It.IsAny()), Times.Once,
"the row inserted for it is removed again");
+ repository.Verify(x => x.DeleteAllButNewestInSlotAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
[Test]
@@ -194,9 +200,9 @@ public async Task RemoveIconAsync_clears_the_key_and_deletes_the_rows()
var repository = new Mock();
repository.Setup(x => x.SetTypeIconKeyAsync(DepartmentId, TypeIconKinds.PoiType, TypeId, null, It.IsAny())).ReturnsAsync(1);
- (await new TypeIconsService(repository.Object).RemoveIconAsync(DepartmentId, TypeIconKinds.PoiType, TypeId)).Should().BeTrue();
+ (await Service(repository.Object).RemoveIconAsync(DepartmentId, TypeIconKinds.PoiType, TypeId)).Should().BeTrue();
- repository.Verify(x => x.DeleteForTypeAsync(DepartmentId, TypeIconKinds.PoiType, TypeId, null, It.IsAny()), Times.Once);
+ repository.Verify(x => x.DeleteSlotAsync(DepartmentId, TypeIconKinds.PoiType, TypeId, null, null, It.IsAny()), Times.Once);
}
[Test]
@@ -205,15 +211,84 @@ public async Task RemoveIconAsync_for_another_departments_type_deletes_nothing()
var repository = new Mock();
repository.Setup(x => x.SetTypeIconKeyAsync(DepartmentId, TypeIconKinds.UnitType, TypeId, null, It.IsAny())).ReturnsAsync(0);
- (await new TypeIconsService(repository.Object).RemoveIconAsync(DepartmentId, TypeIconKinds.UnitType, TypeId)).Should().BeFalse();
+ (await Service(repository.Object).RemoveIconAsync(DepartmentId, TypeIconKinds.UnitType, TypeId)).Should().BeFalse();
- repository.Verify(x => x.DeleteForTypeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ repository.Verify(x => x.DeleteSlotAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestCase(TypeIconKinds.CallType, "p2")]
+ [TestCase(TypeIconKinds.UnitType, "s5")]
+ [TestCase(TypeIconKinds.UnitType, "o4410")]
+ [TestCase(TypeIconKinds.PersonnelRole, "s3")]
+ public async Task UploadVariantIconAsync_stores_the_icon_in_its_slot_and_replaces_only_that_slot(TypeIconKinds kind, string variant)
+ {
+ var repository = new Mock();
+ var cache = new Mock();
+ TypeIcon inserted = null;
+ repository.Setup(x => x.TypeBelongsToDepartmentAsync(DepartmentId, kind, TypeId)).ReturnsAsync(true);
+ repository.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny