diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f66b177b..8421b6b6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -62,6 +62,8 @@ jobs: dockerfile: Web/Resgrid.Web.Eventing/Dockerfile - image: resgridllc/resgridwebmcp dockerfile: Web/Resgrid.Web.Mcp/Dockerfile + - image: resgridllc/resgridwebtts + dockerfile: Web/Resgrid.Web.Tts/Dockerfile - image: resgridllc/resgridworkersconsole dockerfile: Workers/Resgrid.Workers.Console/Dockerfile diff --git a/Core/Resgrid.Config/Resgrid.Config.csproj b/Core/Resgrid.Config/Resgrid.Config.csproj index 0ba4ca4b..23779d20 100644 --- a/Core/Resgrid.Config/Resgrid.Config.csproj +++ b/Core/Resgrid.Config/Resgrid.Config.csproj @@ -1,15 +1,15 @@ - - - net9.0 - Debug;Release;Docker - - - - - - - - - - - \ No newline at end of file + + + net8.0;net9.0 + Debug;Release;Docker + + + + + + + + + + + diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs new file mode 100644 index 00000000..6481041b --- /dev/null +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -0,0 +1,44 @@ +namespace Resgrid.Config +{ + /// + /// Shared configuration for the Resgrid TTS microservice. + /// Values are loaded through ConfigProcessor from ResgridConfig.json or RESGRID:* environment variables. + /// + public static class TtsConfig + { + public static string ServiceBaseUrl = ""; + public static string PlaybackBaseUrl = ""; + public static string StaticPromptAdminKey = ""; + public static int StaticPromptRefreshIntervalMinutes = 1440; + public static int PlaybackMemoryCacheMinutes = 60; + public static int PlaybackCacheControlSeconds = 86400; + + public static string S3Endpoint = ""; + public static string S3AccessKey = ""; + public static string S3SecretKey = ""; + public static string S3Bucket = ""; + public static string S3Region = "us-east-1"; + public static bool S3UseSsl = false; + public static bool S3ForcePathStyle = true; + public static bool S3UsePresignedUrls = true; + public static int S3PresignedUrlExpiryMinutes = 60; + public static string S3PublicBaseUrl = ""; + + public static string DefaultVoice = "en-us"; + public static int DefaultSpeed = 175; + public static int MaxConcurrentGenerations = 4; + public static int MaxTextLength = 1000; + public static string EspeakExecutable = "espeak-ng"; + public static string FfmpegExecutable = "ffmpeg"; + public static string TempDirectory = ""; + public static string CachePrefix = "tts"; + public static int NormalizedSampleRate = 8000; + public static int NormalizedChannels = 1; + public static bool WarmupEnabled = true; + public static string PreGeneratedPrompts = "Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene, goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;Press 0 to go back to the main menu.;Invalid status selection, goodbye.;No status selection made, goodbye.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded."; + + public static int RateLimitPermitLimit = 60; + public static int RateLimitQueueLimit = 10; + public static int RateLimitWindowSeconds = 60; + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx index 42505b69..abfc222f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx @@ -645,4 +645,10 @@ All - \ No newline at end of file + + TTS Language + + + Select the eSpeak-NG language or dialect to use for this department's voice prompts. + + diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx index 768fe3d7..3d09b45f 100644 --- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx +++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx @@ -330,4 +330,10 @@ - \ No newline at end of file + + TTS Language + + + Select the eSpeak-NG language or dialect to use for this department's voice prompts. + + diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 995c97f8..a341d0cc 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -50,5 +50,6 @@ public enum DepartmentSettingTypes MappingUseMapboxOverride = 46, MappingMapboxStyleUrl = 47, MappingMapboxAccessToken = 48, + TtsLanguage = 49, } } diff --git a/Core/Resgrid.Model/EspeakVoiceCatalog.cs b/Core/Resgrid.Model/EspeakVoiceCatalog.cs new file mode 100644 index 00000000..6e96d0af --- /dev/null +++ b/Core/Resgrid.Model/EspeakVoiceCatalog.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Resgrid.Model +{ + public static class EspeakVoiceCatalog + { + private static readonly IReadOnlyList VoicesInternal = new List + { + new TtsVoiceOption("af", "Afrikaans"), + new TtsVoiceOption("sq", "Albanian"), + new TtsVoiceOption("am", "Amharic"), + new TtsVoiceOption("ar", "Arabic"), + new TtsVoiceOption("an", "Aragonese"), + new TtsVoiceOption("hy", "Armenian", "East Armenian"), + new TtsVoiceOption("hyw", "Armenian", "West Armenian"), + new TtsVoiceOption("as", "Assamese"), + new TtsVoiceOption("az", "Azerbaijani"), + new TtsVoiceOption("ba", "Bashkir"), + new TtsVoiceOption("cu", "Chuvash"), + new TtsVoiceOption("eu", "Basque"), + new TtsVoiceOption("be", "Belarusian"), + new TtsVoiceOption("bn", "Bengali"), + new TtsVoiceOption("bpy", "Bishnupriya Manipuri"), + new TtsVoiceOption("bs", "Bosnian"), + new TtsVoiceOption("bg", "Bulgarian"), + new TtsVoiceOption("my", "Burmese"), + new TtsVoiceOption("ca", "Catalan"), + new TtsVoiceOption("chr", "Cherokee", "Western/C.E.D."), + new TtsVoiceOption("yue", "Chinese", "Cantonese"), + new TtsVoiceOption("hak", "Chinese", "Hakka"), + new TtsVoiceOption("haw", "Hawaiian"), + new TtsVoiceOption("cmn", "Chinese", "Mandarin"), + new TtsVoiceOption("hr", "Croatian"), + new TtsVoiceOption("cs", "Czech"), + new TtsVoiceOption("da", "Danish"), + new TtsVoiceOption("nl", "Dutch"), + new TtsVoiceOption("en-us", "English", "American"), + new TtsVoiceOption("en", "English", "British"), + new TtsVoiceOption("en-029", "English", "Caribbean"), + new TtsVoiceOption("en-gb-x-gbclan", "English", "Lancastrian"), + new TtsVoiceOption("en-gb-x-rp", "English", "Received Pronunciation"), + new TtsVoiceOption("en-gb-scotland", "English", "Scottish"), + new TtsVoiceOption("en-gb-x-gbcwmd", "English", "West Midlands"), + new TtsVoiceOption("eo", "Esperanto"), + new TtsVoiceOption("et", "Estonian"), + new TtsVoiceOption("fa", "Persian"), + new TtsVoiceOption("fa-latn", "Persian"), + new TtsVoiceOption("fi", "Finnish"), + new TtsVoiceOption("fr-be", "French", "Belgium"), + new TtsVoiceOption("fr", "French", "France"), + new TtsVoiceOption("fr-ch", "French", "Switzerland"), + new TtsVoiceOption("ga", "Gaelic", "Irish"), + new TtsVoiceOption("gd", "Gaelic", "Scottish"), + new TtsVoiceOption("ka", "Georgian"), + new TtsVoiceOption("de", "German"), + new TtsVoiceOption("grc", "Greek", "Ancient"), + new TtsVoiceOption("el", "Greek", "Modern"), + new TtsVoiceOption("kl", "Greenlandic"), + new TtsVoiceOption("gn", "Guarani"), + new TtsVoiceOption("gu", "Gujarati"), + new TtsVoiceOption("ht", "Haitian Creole"), + new TtsVoiceOption("he", "Hebrew"), + new TtsVoiceOption("hi", "Hindi"), + new TtsVoiceOption("hu", "Hungarian"), + new TtsVoiceOption("is", "Icelandic"), + new TtsVoiceOption("id", "Indonesian"), + new TtsVoiceOption("ia", "Interlingua"), + new TtsVoiceOption("io", "Ido"), + new TtsVoiceOption("it", "Italian"), + new TtsVoiceOption("ja", "Japanese"), + new TtsVoiceOption("kn", "Kannada"), + new TtsVoiceOption("kok", "Konkani"), + new TtsVoiceOption("ko", "Korean"), + new TtsVoiceOption("ku", "Kurdish"), + new TtsVoiceOption("kk", "Kazakh"), + new TtsVoiceOption("ky", "Kyrgyz"), + new TtsVoiceOption("la", "Latin"), + new TtsVoiceOption("lb", "Luxembourgish"), + new TtsVoiceOption("ltg", "Latgalian"), + new TtsVoiceOption("lv", "Latvian"), + new TtsVoiceOption("lfn", "Lingua Franca Nova"), + new TtsVoiceOption("lt", "Lithuanian"), + new TtsVoiceOption("jbo", "Lojban"), + new TtsVoiceOption("mi", "Māori"), + new TtsVoiceOption("mk", "Macedonian"), + new TtsVoiceOption("ms", "Malay"), + new TtsVoiceOption("ml", "Malayalam"), + new TtsVoiceOption("mt", "Maltese"), + new TtsVoiceOption("mr", "Marathi"), + new TtsVoiceOption("nci", "Nahuatl", "Classical"), + new TtsVoiceOption("ne", "Nepali"), + new TtsVoiceOption("nb", "Norwegian Bokmål"), + new TtsVoiceOption("nog", "Nogai"), + new TtsVoiceOption("or", "Oriya"), + new TtsVoiceOption("om", "Oromo"), + new TtsVoiceOption("pap", "Papiamento"), + new TtsVoiceOption("py", "Pyash"), + new TtsVoiceOption("pl", "Polish"), + new TtsVoiceOption("qdb", "Lang Belta"), + new TtsVoiceOption("qu", "Quechua"), + new TtsVoiceOption("quc", "K'iche'"), + new TtsVoiceOption("qya", "Quenya"), + new TtsVoiceOption("pt", "Portuguese", "Portugal"), + new TtsVoiceOption("pt-br", "Portuguese", "Brazil"), + new TtsVoiceOption("pa", "Punjabi"), + new TtsVoiceOption("piqd", "Klingon"), + new TtsVoiceOption("ro", "Romanian"), + new TtsVoiceOption("ru", "Russian"), + new TtsVoiceOption("ru-lv", "Russian", "Latvia"), + new TtsVoiceOption("uk", "Ukrainian"), + new TtsVoiceOption("sjn", "Sindarin"), + new TtsVoiceOption("sr", "Serbian"), + new TtsVoiceOption("tn", "Setswana"), + new TtsVoiceOption("sd", "Sindhi"), + new TtsVoiceOption("shn", "Shan (Tai Yai)"), + new TtsVoiceOption("si", "Sinhala"), + new TtsVoiceOption("sk", "Slovak"), + new TtsVoiceOption("sl", "Slovenian"), + new TtsVoiceOption("smj", "Lule Saami"), + new TtsVoiceOption("es", "Spanish", "Spain"), + new TtsVoiceOption("es-419", "Spanish", "Latin American"), + new TtsVoiceOption("sw", "Swahili"), + new TtsVoiceOption("sv", "Swedish"), + new TtsVoiceOption("ta", "Tamil"), + new TtsVoiceOption("th", "Thai"), + new TtsVoiceOption("tk", "Turkmen"), + new TtsVoiceOption("tt", "Tatar"), + new TtsVoiceOption("te", "Telugu"), + new TtsVoiceOption("tr", "Turkish"), + new TtsVoiceOption("ug", "Uyghur"), + new TtsVoiceOption("ur", "Urdu"), + new TtsVoiceOption("uz", "Uzbek"), + new TtsVoiceOption("vi-vn-x-central", "Vietnamese", "Central Vietnam"), + new TtsVoiceOption("vi", "Vietnamese", "Northern Vietnam"), + new TtsVoiceOption("vi-vn-x-south", "Vietnamese", "Southern Vietnam"), + new TtsVoiceOption("cy", "Welsh"), + }; + + private static readonly Dictionary VoiceLookup = VoicesInternal.ToDictionary(x => x.Identifier, StringComparer.OrdinalIgnoreCase); + + public const string DefaultIdentifier = "en-us"; + + public static IReadOnlyList Voices => VoicesInternal; + + public static bool TryNormalizeIdentifier(string voice, out string normalizedVoice) + { + normalizedVoice = null; + + if (string.IsNullOrWhiteSpace(voice)) + return false; + + if (!VoiceLookup.TryGetValue(voice.Trim(), out var option)) + return false; + + normalizedVoice = option.Identifier; + return true; + } + + public static string GetDisplayName(string voice) + { + if (TryGetOption(voice, out var option)) + return option.DisplayName; + + return string.IsNullOrWhiteSpace(voice) ? DefaultIdentifier : voice.Trim(); + } + + public static bool TryGetOption(string voice, out TtsVoiceOption option) + { + option = null; + + if (string.IsNullOrWhiteSpace(voice)) + return false; + + return VoiceLookup.TryGetValue(voice.Trim(), out option); + } + } + + public sealed class TtsVoiceOption + { + public TtsVoiceOption(string identifier, string language, string accent = null) + { + Identifier = identifier; + Language = language; + Accent = accent; + } + + public string Identifier { get; } + public string Language { get; } + public string Accent { get; } + public string DisplayName => string.IsNullOrWhiteSpace(Accent) ? $"{Language} ({Identifier})" : $"{Language} - {Accent} ({Identifier})"; + } +} diff --git a/Core/Resgrid.Model/Services/ICommunicationTestService.cs b/Core/Resgrid.Model/Services/ICommunicationTestService.cs index 49aa1cb3..ce45ff1e 100644 --- a/Core/Resgrid.Model/Services/ICommunicationTestService.cs +++ b/Core/Resgrid.Model/Services/ICommunicationTestService.cs @@ -25,6 +25,7 @@ public interface ICommunicationTestService Task RecordEmailResponseAsync(string responseToken); Task RecordVoiceResponseAsync(string responseToken); Task RecordPushResponseAsync(string responseToken); + Task GetDepartmentIdByResponseTokenAsync(string responseToken); Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default); Task CompleteExpiredRunsAsync(CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs index 1eb3a01a..1e5a334a 100644 --- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs @@ -169,6 +169,8 @@ public interface IDepartmentSettingsService /// Task<System.String>. Task GetDispatchEmailForDepartmentAsync(int departmentId); + Task GetTtsLanguageForDepartmentAsync(int departmentId); + /// /// Gets the disable automatic available for department by user identifier asynchronous. /// diff --git a/Core/Resgrid.Model/Services/ITtsAudioService.cs b/Core/Resgrid.Model/Services/ITtsAudioService.cs new file mode 100644 index 00000000..aa3c025e --- /dev/null +++ b/Core/Resgrid.Model/Services/ITtsAudioService.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + public interface ITtsAudioService + { + Task GenerateSpeechUrlAsync(string text, string voice = null, int? speed = null, CancellationToken cancellationToken = default); + + Task RegenerateStaticPromptsAsync(IEnumerable prompts, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs new file mode 100644 index 00000000..36c460eb --- /dev/null +++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + public static class TwilioVoicePromptCatalog + { + public const string CallClosed = "This call has been closed. Goodbye."; + public const string RespondingToScene = "You have been marked responding to the scene, goodbye."; + public const string InvalidSelection = "Sorry, that was not a valid selection."; + public const string VerificationGreeting = "Hello, this is Resgrid calling with your verification code."; + public const string VerificationClosing = "That was your Resgrid verification code. Goodbye."; + public const string InboundVoiceUnavailable = "Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye."; + public const string VoiceVerificationFailure = "We couldn't complete your verification call. Please request a new code and try again. Goodbye."; + public const string MainMenuSelectionIntro = "Please select from the following options."; + public const string MainMenuActiveCalls = "To list current active calls, press 1."; + public const string MainMenuUserStatuses = "To list current user statuses, press 2."; + public const string MainMenuUnitStatuses = "To list current unit statuses, press 3."; + public const string MainMenuCalendarEvents = "To list upcoming calendar events, press 4."; + public const string MainMenuShifts = "To list upcoming shifts, press 5."; + public const string MainMenuSetStatus = "To set your current status, press 6."; + public const string MainMenuSetStaffing = "To set your current staffing level, press 7."; + public const string RepeatAndRespondToScene = "Press 0 to repeat. Press 1 to respond to the scene."; + public const string GoBackToMainMenu = "Press 0 to go back to the main menu."; + public const string StatusSelectionIntro = "To set your current status, please select from the following options."; + public const string StaffingSelectionIntro = "To set your current staffing, please select from the following options."; + public const string InvalidStatusSelection = "Invalid status selection, goodbye."; + public const string NoStatusSelection = "No status selection made, goodbye."; + public const string InvalidStaffingSelection = "Invalid staffing selection. Returning to the main menu."; + public const string NoStaffingSelection = "No staffing selection made. Returning to the main menu."; + public const string CommunicationTestRecorded = "Thank you. Your response has been recorded."; + + public static string CallClosedByNumber(string callNumber) => $"This call, Id {callNumber} has been closed. Goodbye."; + + public static string RespondingToStation(string stationName) => $"You have been marked responding to {stationName}, goodbye."; + + public static string RespondToStationOption(int digit, string stationName) => $"Press {digit} to respond to {stationName}."; + + public static string VerificationCode(string spokenCode) => $"Your verification code is: {spokenCode}."; + + public static string MainMenuGreeting(string firstName, string departmentName) => $"Hello {firstName}, this is the Resgrid automated voice system for {departmentName}."; + + public static string StatusOption(int digit, string buttonText) => $"Press {digit} for {buttonText}."; + + public static string StaffingOption(int digit, string buttonText) => $"Press {digit} for {buttonText}."; + + public static string StatusMarked(string buttonText) => $"You have been marked as {buttonText}, goodbye."; + + public static string StaffingMarked(string buttonText) => $"You have been marked as {buttonText}. Goodbye."; + + public static IReadOnlyCollection GetStaticPrompts() + { + return new[] + { + CallClosed, + RespondingToScene, + InvalidSelection, + VerificationGreeting, + VerificationClosing, + InboundVoiceUnavailable, + VoiceVerificationFailure, + MainMenuSelectionIntro, + MainMenuActiveCalls, + MainMenuUserStatuses, + MainMenuUnitStatuses, + MainMenuCalendarEvents, + MainMenuShifts, + MainMenuSetStatus, + MainMenuSetStaffing, + RepeatAndRespondToScene, + GoBackToMainMenu, + StatusSelectionIntro, + StaffingSelectionIntro, + InvalidStatusSelection, + NoStatusSelection, + InvalidStaffingSelection, + NoStaffingSelection, + CommunicationTestRecorded + }; + } + } +} diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs index 0fa325bb..a527619c 100644 --- a/Core/Resgrid.Services/CommunicationTestService.cs +++ b/Core/Resgrid.Services/CommunicationTestService.cs @@ -300,6 +300,15 @@ public async Task RecordPushResponseAsync(string responseToken) return await RecordResponseByTokenAsync(responseToken, CommunicationTestChannel.Push); } + public async Task GetDepartmentIdByResponseTokenAsync(string responseToken) + { + if (string.IsNullOrWhiteSpace(responseToken)) + return null; + + var result = await _communicationTestResultRepository.GetResultByResponseTokenAsync(responseToken); + return result?.DepartmentId; + } + public async Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default) { var now = DateTime.UtcNow; diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 90067085..991713ff 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -19,6 +19,7 @@ public class DepartmentSettingsService : IDepartmentSettingsService private static string PaddleCustomerCacheKey = "DSetPaddleCus_{0}"; private static string BigBoardCenterGps = "DSetBBCenterGps_{0}"; private static string StaffingSupressInfo = "DSetStaffingSupress_{0}"; + private static string TtsLanguageCacheKey = "DSetTtsLanguage_{0}"; private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}"; private static TimeSpan LongCacheLength = TimeSpan.FromDays(14); private static TimeSpan ThatsNotLongThisIsLongCacheLength = TimeSpan.FromDays(365); @@ -65,6 +66,9 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting case DepartmentSettingTypes.StaffingSuppressStaffingLevels: await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId)); break; + case DepartmentSettingTypes.TtsLanguage: + await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId)); + break; case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus: await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId)); break; @@ -464,6 +468,27 @@ public async Task GetDispatchEmailForDepartmentAsync(int departmentId) return null; } + public async Task GetTtsLanguageForDepartmentAsync(int departmentId) + { + async Task getSetting() + { + var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.TtsLanguage); + + if (settingValue != null && EspeakVoiceCatalog.TryNormalizeIdentifier(settingValue.Setting, out var normalizedSetting)) + return normalizedSetting; + + return GetDefaultTtsLanguage(); + } + + if (Config.SystemBehaviorConfig.CacheEnabled) + { + return await _cacheProvider.RetrieveAsync(string.Format(TtsLanguageCacheKey, departmentId), + getSetting, LongCacheLength); + } + + return await getSetting(); + } + public async Task GetDepartmentUpdateTimestampAsync(int departmentId) { var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.UpdateTimestamp); @@ -841,5 +866,16 @@ public async Task GetSettingByTypeAsync(int departmentId, Dep { return await GetSettingByDepartmentIdType(departmentId, type); } + + private static string GetDefaultTtsLanguage() + { + if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedVoice)) + return normalizedVoice; + + if (!string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice)) + return TtsConfig.DefaultVoice.Trim(); + + return EspeakVoiceCatalog.DefaultIdentifier; + } } } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 0ec55973..c23f4939 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -1,11 +1,17 @@ -using Autofac; +using System; +using Autofac; using Resgrid.Model.Services; using Resgrid.Services.CallEmailTemplates; +using RestSharp; +using RestSharp.Serializers.NewtonsoftJson; +using Resgrid.Config; namespace Resgrid.Services { public class ServicesModule : Module { + private const string TtsRestClientRegistrationName = "tts-rest-client"; + protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().InstancePerLifetimeScope(); @@ -75,6 +81,26 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.Register(_ => + { + if (string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl)) + throw new InvalidOperationException("TtsConfig.ServiceBaseUrl must be configured before using the TTS service."); + + var options = new RestClientOptions(TtsConfig.ServiceBaseUrl.TrimEnd('/')) + { + Timeout = TimeSpan.FromSeconds(5) + }; + + return new RestClient(options, configureSerialization: serializer => serializer.UseNewtonsoftJson()); + }) + .Named(TtsRestClientRegistrationName) + .SingleInstance(); + builder.RegisterType() + .As() + .WithParameter( + (parameter, _) => parameter.ParameterType == typeof(RestClient), + (_, context) => context.ResolveNamed(TtsRestClientRegistrationName)) + .InstancePerLifetimeScope(); // SSO / Security Policy builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/TtsAudioService.cs b/Core/Resgrid.Services/TtsAudioService.cs new file mode 100644 index 00000000..a60ce57d --- /dev/null +++ b/Core/Resgrid.Services/TtsAudioService.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.Services; +using RestSharp; + +namespace Resgrid.Services +{ + public class TtsAudioService : ITtsAudioService + { + private const string AdminKeyHeaderName = "X-Resgrid-Admin-Key"; + private readonly RestClient _restClient; + + public TtsAudioService(RestClient restClient) + { + _restClient = restClient ?? throw new ArgumentNullException(nameof(restClient)); + } + + public async Task GenerateSpeechUrlAsync(string text, string voice = null, int? speed = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is required.", nameof(text)); + + var request = new RestRequest("tts", Method.Post); + request.AddJsonBody(new GenerateSpeechRequest + { + Text = text, + Voice = string.IsNullOrWhiteSpace(voice) ? TtsConfig.DefaultVoice : voice, + Speed = speed ?? TtsConfig.DefaultSpeed + }); + + var response = await _restClient.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url)) + throw CreateRequestFailure("generate speech audio", response); + + return new Uri(response.Data.Url, UriKind.Absolute); + } + + public async Task RegenerateStaticPromptsAsync(IEnumerable prompts, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(TtsConfig.StaticPromptAdminKey)) + throw new InvalidOperationException("TtsConfig.StaticPromptAdminKey must be configured before refreshing static prompts."); + + var promptRequests = prompts? + .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) + .Select(prompt => new GenerateSpeechRequest + { + Text = prompt.Trim(), + Voice = TtsConfig.DefaultVoice, + Speed = TtsConfig.DefaultSpeed + }) + .ToList() ?? new List(); + + if (!promptRequests.Any()) + throw new ArgumentException("At least one static prompt is required.", nameof(prompts)); + + var request = new RestRequest("tts/admin/static-prompts", Method.Post); + request.AddHeader(AdminKeyHeaderName, TtsConfig.StaticPromptAdminKey); + request.AddJsonBody(new RegenerateStaticPromptsRequest + { + Prompts = promptRequests + }); + + var response = await _restClient.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful) + throw CreateRequestFailure("regenerate static prompts", response); + } + + private static Exception CreateRequestFailure(string operation, RestResponse response) + { + var status = response.StatusCode == 0 ? "no-response" : response.StatusCode.ToString(); + var detail = response.ErrorException?.Message; + + if (string.IsNullOrWhiteSpace(detail)) + detail = response.ErrorMessage; + + if (string.IsNullOrWhiteSpace(detail) && !string.IsNullOrWhiteSpace(response.StatusDescription)) + detail = response.StatusDescription; + + if (string.IsNullOrWhiteSpace(detail)) + detail = "No additional error details were provided."; + + return new InvalidOperationException($"The TTS service failed to {operation}. Status: {status}. Detail: {detail}"); + } + + private sealed class GenerateSpeechRequest + { + public string Text { get; set; } + public string Voice { get; set; } + public int Speed { get; set; } + } + + private sealed class GenerateSpeechResponse + { + public string Url { get; set; } + } + + private sealed class RegenerateStaticPromptsRequest + { + public List Prompts { get; set; } + } + } +} diff --git a/Resgrid.sln b/Resgrid.sln index de74e3a9..6e87b529 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -98,6 +98,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Quidjibo.Postgres", "Worker EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Weather", "Providers\Resgrid.Providers.Weather\Resgrid.Providers.Weather.csproj", "{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Tts", "Web\Resgrid.Web.Tts\Resgrid.Web.Tts.csproj", "{684FA75D-6712-41AE-A396-B6E0918899C0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Azure|Any CPU = Azure|Any CPU @@ -1344,6 +1346,42 @@ Global {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x86.Build.0 = Debug|Any CPU {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.ActiveCfg = Debug|Any CPU {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|Any CPU.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x86.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x86.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x64.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x64.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x86.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x86.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x64.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x64.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x86.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x64.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|Any CPU.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|Any CPU.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x86.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x86.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x64.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x64.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|Any CPU.Build.0 = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x86.ActiveCfg = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x86.Build.0 = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x64.ActiveCfg = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x64.Build.0 = Release|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|Any CPU.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x86.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x86.Build.0 = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x64.ActiveCfg = Debug|Any CPU + {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1384,6 +1422,7 @@ Global {89331D76-C527-479D-8F30-8033A04C625F} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} {744B3BB7-B5F6-4002-93E2-FC0821D41963} = {89331D76-C527-479D-8F30-8033A04C625F} {FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} + {684FA75D-6712-41AE-A396-B6E0918899C0} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF} diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index 9788e569..bfcbbe6f 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -56,6 +56,8 @@ + + diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs new file mode 100644 index 00000000..6c283d1b --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs @@ -0,0 +1,101 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentSettingsServiceTtsLanguageTests + { + private Mock _departmentSettingsRepository; + private Mock _addressService; + private Mock _geoLocationProvider; + private Mock _cacheProvider; + private DepartmentSettingsService _service; + private bool _originalCacheEnabled; + private string _originalDefaultVoice; + + [SetUp] + public void SetUp() + { + _originalCacheEnabled = global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled; + _originalDefaultVoice = TtsConfig.DefaultVoice; + + _departmentSettingsRepository = new Mock(); + _addressService = new Mock(); + _geoLocationProvider = new Mock(); + _cacheProvider = new Mock(); + + _service = new DepartmentSettingsService( + _departmentSettingsRepository.Object, + _addressService.Object, + _geoLocationProvider.Object, + _cacheProvider.Object); + + global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false; + TtsConfig.DefaultVoice = "en-us"; + } + + [TearDown] + public void TearDown() + { + global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = _originalCacheEnabled; + TtsConfig.DefaultVoice = _originalDefaultVoice; + } + + [Test] + public async Task should_return_department_tts_language_override_when_supported() + { + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + Setting = "es-419", + SettingType = (int)DepartmentSettingTypes.TtsLanguage + }); + + var result = await _service.GetTtsLanguageForDepartmentAsync(7); + + result.Should().Be("es-419"); + } + + [Test] + public async Task should_fall_back_to_default_tts_language_when_setting_missing() + { + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage)) + .ReturnsAsync((DepartmentSetting)null); + + var result = await _service.GetTtsLanguageForDepartmentAsync(7); + + result.Should().Be("en-us"); + } + + [Test] + public async Task should_fall_back_to_default_tts_language_when_setting_is_invalid() + { + TtsConfig.DefaultVoice = "fr"; + + _departmentSettingsRepository + .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage)) + .ReturnsAsync(new DepartmentSetting + { + DepartmentId = 7, + Setting = "not-a-real-voice", + SettingType = (int)DepartmentSettingTypes.TtsLanguage + }); + + var result = await _service.GetTtsLanguageForDepartmentAsync(7); + + result.Should().Be("fr"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs new file mode 100644 index 00000000..073b7f93 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Controllers.v4; +using Resgrid.Web.Services.Twilio; +using Twilio.TwiML; +using Twilio.TwiML.Voice; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class CommunicationTestResponseControllerTests + { + private Mock _communicationTestServiceMock; + private Mock _departmentSettingsServiceMock; + private Mock _twilioVoiceResponseServiceMock; + private CommunicationTestResponseController _controller; + + [SetUp] + public void SetUp() + { + _communicationTestServiceMock = new Mock(MockBehavior.Strict); + _departmentSettingsServiceMock = new Mock(MockBehavior.Strict); + _twilioVoiceResponseServiceMock = new Mock(MockBehavior.Strict); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((response, text, _, __) => + { + response.Append(new Play + { + Url = new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav") + }); + return System.Threading.Tasks.Task.CompletedTask; + }); + + _controller = new CommunicationTestResponseController( + _communicationTestServiceMock.Object, + _departmentSettingsServiceMock.Object, + _twilioVoiceResponseServiceMock.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + [Test] + public async System.Threading.Tasks.Task voice_webhook_should_skip_department_lookup_when_token_missing() + { + var result = await _controller.VoiceWebhook(null, "1"); + + var content = result.Content; + content.Should().Contain(""); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.CommunicationTestRecorded, It.IsAny(), null), Times.Once); + _communicationTestServiceMock.Verify(x => x.GetDepartmentIdByResponseTokenAsync(It.IsAny()), Times.Never); + _communicationTestServiceMock.Verify(x => x.RecordVoiceResponseAsync(It.IsAny()), Times.Never); + _departmentSettingsServiceMock.Verify(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny()), Times.Never); + } + + [Test] + public async System.Threading.Tasks.Task department_tts_language_helper_should_return_null_without_lookup_for_blank_token() + { + var method = typeof(CommunicationTestResponseController).GetMethod("GetDepartmentTtsLanguageAsync", BindingFlags.Instance | BindingFlags.NonPublic); + + var task = (System.Threading.Tasks.Task)method!.Invoke(_controller, new object[] { " " }); + var result = await task; + + result.Should().BeNull(); + _communicationTestServiceMock.Verify(x => x.GetDepartmentIdByResponseTokenAsync(It.IsAny()), Times.Never); + _departmentSettingsServiceMock.Verify(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 60415c57..78978c51 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,9 @@ using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Services.Controllers; +using Resgrid.Web.Services.Twilio; +using Twilio.TwiML; +using Twilio.TwiML.Voice; namespace Resgrid.Tests.Web.Services { @@ -36,6 +40,7 @@ public class TwilioControllerVoiceVerificationTests : TestBase private Mock _calendarServiceMock; private Mock _communicationTestServiceMock; private Mock _encryptionServiceMock; + private Mock _twilioVoiceResponseServiceMock; protected override void Before_all_tests() { @@ -58,6 +63,17 @@ protected override void Before_all_tests() _calendarServiceMock = new Mock(); _communicationTestServiceMock = new Mock(); _encryptionServiceMock = new Mock(); + _twilioVoiceResponseServiceMock = new Mock(); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((response, text, _, __) => + { + response.Append(new Play + { + Url = new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav") + }); + return System.Threading.Tasks.Task.CompletedTask; + }); } private TwilioController BuildController() @@ -81,11 +97,12 @@ private TwilioController BuildController() _usersServiceMock.Object, _calendarServiceMock.Object, _communicationTestServiceMock.Object, - _encryptionServiceMock.Object); + _encryptionServiceMock.Object, + _twilioVoiceResponseServiceMock.Object); } [Test] - public async Task should_mark_home_code_consumed_after_successful_voice_generation() + public async System.Threading.Tasks.Task should_mark_home_code_consumed_after_successful_voice_generation() { var profile = new UserProfile { @@ -99,6 +116,7 @@ public async Task should_mark_home_code_consumed_after_successful_voice_generati _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", true)).ReturnsAsync(profile); _encryptionServiceMock.Setup(x => x.Decrypt("ENC:123456")).Returns("123456"); _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _departmentSettingsServiceMock.Setup(x => x.GetTtsLanguageForDepartmentAsync(7)).ReturnsAsync("es"); _userProfileServiceMock .Setup(x => x.SaveProfileAsync(7, It.IsAny(), It.IsAny())) .Callback((_, p, _) => savedProfile = p) @@ -107,14 +125,18 @@ public async Task should_mark_home_code_consumed_after_successful_voice_generati var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber); var content = ((ContentResult)result).Content; - content.Should().Contain("Your verification code is: 1, 2, 3, 4, 5, 6."); + content.Should().Contain(""); + content.Should().Contain(Uri.EscapeDataString("Your verification code is: 1, 2, 3, 4, 5, 6.")); savedProfile.Should().NotBeNull(); savedProfile!.HomeVerificationVoiceCodeConsumed.Should().BeTrue(); savedProfile.HomeVerificationCode.Should().Be("ENC:123456"); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "Hello, this is Resgrid calling with your verification code.", It.IsAny(), "es"), Times.Once); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "Your verification code is: 1, 2, 3, 4, 5, 6.", It.IsAny(), "es"), Times.Exactly(3)); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "That was your Resgrid verification code. Goodbye.", It.IsAny(), "es"), Times.Once); } [Test] - public async Task should_return_generic_message_when_home_code_already_consumed() + public async System.Threading.Tasks.Task should_return_generic_message_when_home_code_already_consumed() { var profile = new UserProfile { @@ -129,13 +151,15 @@ public async Task should_return_generic_message_when_home_code_already_consumed( var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber); var content = ((ContentResult)result).Content; - content.Should().Contain("We couldn't complete your verification call."); + content.Should().Contain(""); + content.Should().Contain(Uri.EscapeDataString("We couldn't complete your verification call. Please request a new code and try again. Goodbye.")); content.Should().NotContain("123456"); _userProfileServiceMock.Verify(x => x.SaveProfileAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once); } [Test] - public async Task should_return_generic_message_when_decryption_fails() + public async System.Threading.Tasks.Task should_return_generic_message_when_decryption_fails() { var profile = new UserProfile { @@ -150,8 +174,59 @@ public async Task should_return_generic_message_when_decryption_fails() var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber); var content = ((ContentResult)result).Content; - content.Should().Contain("We couldn't complete your verification call."); + content.Should().Contain(""); + content.Should().Contain(Uri.EscapeDataString("We couldn't complete your verification call. Please request a new code and try again. Goodbye.")); content.Should().NotContain("broken"); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async System.Threading.Tasks.Task should_redirect_invalid_status_selection_back_to_user_scoped_menu() + { + var department = new Department { DepartmentId = 7, Name = "Dept 1" }; + var profile = new UserProfile { UserId = "user1", FirstName = "Pat" }; + + _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = 1, ButtonText = "Available" } + }); + + var controller = BuildController(); + var action = typeof(TwilioController).GetMethod(nameof(TwilioController.InboundVoiceActionStatus)); + dynamic request = Activator.CreateInstance(action!.GetParameters()[1].ParameterType); + request.Digits = "9"; + var result = await (Task)action.Invoke(controller, new object[] { "user1", request }); + + var content = ((ContentResult)result).Content; + content.Should().Contain("https://resgridapi.local/api/Twilio/InboundVoiceAction?userId=user1"); + content.Should().NotContain("https://resgridapi.local/api/Twilio/InboundVoice"); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.InvalidStatusSelection, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async System.Threading.Tasks.Task should_redirect_missing_status_selection_back_to_user_scoped_menu() + { + var department = new Department { DepartmentId = 7, Name = "Dept 1" }; + var profile = new UserProfile { UserId = "user1", FirstName = "Pat" }; + + _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = 1, ButtonText = "Available" } + }); + + var controller = BuildController(); + var action = typeof(TwilioController).GetMethod(nameof(TwilioController.InboundVoiceActionStatus)); + var request = Activator.CreateInstance(action!.GetParameters()[1].ParameterType); + var result = await (Task)action.Invoke(controller, new object[] { "user1", request }); + + var content = ((ContentResult)result).Content; + content.Should().Contain("https://resgridapi.local/api/Twilio/InboundVoiceAction?userId=user1"); + content.Should().NotContain("https://resgridapi.local/api/Twilio/InboundVoice"); + _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.NoStatusSelection, It.IsAny(), It.IsAny()), Times.Once); } } } diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs new file mode 100644 index 00000000..505719a1 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Twilio; +using Twilio.TwiML; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class TwilioVoiceResponseServiceTests + { + [Test] + public async Task append_prompt_async_should_reuse_generated_tts_url_within_request_scope() + { + var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav"); + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny())) + .ReturnsAsync(promptUrl); + + var service = new TwilioVoiceResponseService(ttsAudioService.Object); + var response = new VoiceResponse(); + + await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None); + await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None); + + var xml = response.ToString(); + + xml.Should().Contain(promptUrl.ToString()); + xml.Split("").Length.Should().Be(3); + ttsAudioService.Verify( + x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()), + Times.Once); + } + + [Test] + public async Task append_prompt_async_should_scope_cache_entries_by_voice() + { + var spanishPromptUrl = new Uri("https://tts.example.com/tts/audio/es.wav"); + var frenchPromptUrl = new Uri("https://tts.example.com/tts/audio/fr.wav"); + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "es", null, It.IsAny())) + .ReturnsAsync(spanishPromptUrl); + ttsAudioService + .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "fr", null, It.IsAny())) + .ReturnsAsync(frenchPromptUrl); + + var service = new TwilioVoiceResponseService(ttsAudioService.Object); + var response = new VoiceResponse(); + + await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "es"); + await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "es"); + await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "fr"); + + var xml = response.ToString(); + + xml.Should().Contain(spanishPromptUrl.ToString()); + xml.Should().Contain(frenchPromptUrl.ToString()); + xml.Split("").Length.Should().Be(4); + ttsAudioService.Verify( + x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "es", null, It.IsAny()), + Times.Once); + ttsAudioService.Verify( + x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "fr", null, It.IsAny()), + Times.Once); + } + + [Test] + public async Task append_prompt_async_should_generate_cached_prompt_without_request_cancellation_token() + { + var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav"); + var requestCancellation = new CancellationTokenSource(); + var capturedToken = CancellationToken.None; + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny())) + .Callback((_, _, _, token) => capturedToken = token) + .ReturnsAsync(promptUrl); + + var service = new TwilioVoiceResponseService(ttsAudioService.Object); + var response = new VoiceResponse(); + + await service.AppendPromptAsync(response, "Hello from Resgrid", requestCancellation.Token); + + capturedToken.CanBeCanceled.Should().BeFalse(); + ttsAudioService.Verify( + x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()), + Times.Once); + } + + [Test] + public async Task append_prompt_async_should_not_evict_cached_generation_when_a_waiting_request_is_cancelled() + { + var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav"); + var generationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var promptGeneration = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny())) + .Returns((_, _, _, _) => + { + generationStarted.TrySetResult(true); + return promptGeneration.Task; + }); + + var service = new TwilioVoiceResponseService(ttsAudioService.Object); + using var requestCancellation = new CancellationTokenSource(); + var cancelledResponse = new VoiceResponse(); + var cancelledAppendTask = service.AppendPromptAsync(cancelledResponse, "Hello from Resgrid", requestCancellation.Token); + + await generationStarted.Task; + requestCancellation.Cancel(); + + await FluentActions + .Awaiting(() => cancelledAppendTask) + .Should() + .ThrowAsync(); + + var response = new VoiceResponse(); + var secondAppendTask = service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None); + + promptGeneration.SetResult(promptUrl); + await secondAppendTask; + + response.ToString().Should().Contain(promptUrl.ToString()); + ttsAudioService.Verify( + x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()), + Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs new file mode 100644 index 00000000..e8f828c0 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class CacheServiceTests + { + private Mock _storageService; + private Mock _distributedCache; + private Dictionary _cacheEntries; + private CacheService _service; + + [SetUp] + public void SetUp() + { + _storageService = new Mock(); + _distributedCache = new Mock(MockBehavior.Strict); + _cacheEntries = new Dictionary(StringComparer.Ordinal); + _distributedCache + .Setup(x => x.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string key, CancellationToken _) => _cacheEntries.TryGetValue(key, out var value) ? value : null); + _distributedCache + .Setup(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((key, value, _, _) => _cacheEntries[key] = value.ToArray()) + .Returns(Task.CompletedTask); + _distributedCache + .Setup(x => x.RemoveAsync(It.IsAny(), It.IsAny())) + .Callback((key, _) => _cacheEntries.Remove(key)) + .Returns(Task.CompletedTask); + _service = new CacheService( + _storageService.Object, + _distributedCache.Object, + Options.Create(new TtsOptions + { + CachePrefix = "tts", + PlaybackMemoryCacheMinutes = 60 + })); + } + + [Test] + public void create_cache_key_should_be_deterministic_for_same_inputs() + { + var first = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + var second = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + + first.Should().Be(second); + first.ObjectKey.Should().Be($"tts/{first.Hash}.wav"); + } + + [Test] + public void create_cache_key_should_change_when_inputs_change() + { + var baseline = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + var differentText = _service.CreateCacheKey("Press 2 for no", "en-us", 175); + var differentVoice = _service.CreateCacheKey("Press 1 for yes", "en-gb", 175); + var differentSpeed = _service.CreateCacheKey("Press 1 for yes", "en-us", 200); + + differentText.Hash.Should().NotBe(baseline.Hash); + differentVoice.Hash.Should().NotBe(baseline.Hash); + differentSpeed.Hash.Should().NotBe(baseline.Hash); + } + + [Test] + public async Task store_async_should_seed_redis_cache_for_immediate_playback() + { + var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + var audioBytes = new byte[] { 1, 2, 3, 4 }; + + _storageService + .Setup(x => x.UploadAsync(cacheKey.ObjectKey, It.IsAny(), "audio/wav", It.IsAny())) + .Returns(Task.CompletedTask); + _storageService + .Setup(x => x.GetObjectUrlAsync(cacheKey.ObjectKey, It.IsAny())) + .ReturnsAsync(new Uri("https://tts.example.com/tts/audio/abc.wav")); + + await _service.StoreAsync(cacheKey, audioBytes, CancellationToken.None); + + var audio = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None); + + audio.Should().NotBeNull(); + audio!.AudioBytes.Should().Equal(audioBytes); + audio.ContentType.Should().Be("audio/wav"); + _storageService.Verify(x => x.GetObjectAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task try_get_audio_async_should_cache_storage_result_in_redis() + { + var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + var storageAudio = new TtsAudioContent( + new byte[] { 9, 8, 7 }, + "audio/wav", + "\"etag\"", + DateTimeOffset.UtcNow); + + _storageService + .Setup(x => x.GetObjectAsync(cacheKey.ObjectKey, It.IsAny())) + .ReturnsAsync(storageAudio); + + var first = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None); + var second = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None); + + first.Should().BeEquivalentTo(storageAudio); + second.Should().BeEquivalentTo(storageAudio); + _storageService.Verify(x => x.GetObjectAsync(cacheKey.ObjectKey, It.IsAny()), Times.Once); + } + + [Test] + public async Task try_get_cached_url_async_should_skip_s3_exists_when_audio_is_already_in_redis() + { + var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175); + var cachedUrl = new Uri("https://tts.example.com/tts/audio/cached.wav"); + + _storageService + .Setup(x => x.UploadAsync(cacheKey.ObjectKey, It.IsAny(), "audio/wav", It.IsAny())) + .Returns(Task.CompletedTask); + _storageService + .Setup(x => x.GetObjectUrlAsync(cacheKey.ObjectKey, It.IsAny())) + .ReturnsAsync(cachedUrl); + + await _service.StoreAsync(cacheKey, new byte[] { 5, 4, 3, 2 }, CancellationToken.None); + + var result = await _service.TryGetCachedUrlAsync(cacheKey, CancellationToken.None); + + result.Should().Be(cachedUrl); + _storageService.Verify(x => x.ExistsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs new file mode 100644 index 00000000..58c196b5 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class PromptWarmupHostedServiceTests + { + [Test] + public async Task execute_async_should_log_and_swallow_unexpected_warmup_failures() + { + var failure = new InvalidOperationException("warmup failed"); + var ttsService = new Mock(MockBehavior.Strict); + ttsService + .Setup(x => x.WarmPromptsAsync(It.IsAny())) + .ThrowsAsync(failure); + var logger = new RecordingLogger(); + var service = new PromptWarmupHostedService( + ttsService.Object, + Options.Create(new TtsOptions + { + WarmupEnabled = true, + PreGeneratedPrompts = new List { "Prompt 1" } + }), + logger); + + await InvokeExecuteAsync(service, CancellationToken.None); + + logger.Entries.Should().ContainSingle(x => + x.Level == LogLevel.Error && + x.Exception == failure && + x.Message == "TTS prompt warmup failed but will not stop host."); + ttsService.Verify(x => x.WarmPromptsAsync(It.IsAny()), Times.Once); + } + + private static Task InvokeExecuteAsync(PromptWarmupHostedService service, CancellationToken cancellationToken) + { + var method = typeof(PromptWarmupHostedService).GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.NonPublic); + return (Task)method!.Invoke(service, new object[] { cancellationToken })!; + } + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable BeginScope(TState state) + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + Entries.Add(new LogEntry(logLevel, exception, formatter(state, exception))); + } + + public sealed record LogEntry(LogLevel Level, Exception Exception, string Message); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs new file mode 100644 index 00000000..7b3f3472 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Amazon.S3; +using Amazon.S3.Model; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class S3StorageServiceTests + { + [Test] + public async Task upload_async_should_buffer_non_seekable_stream_for_retries() + { + var uploadedPayloads = new List(); + var attempt = 0; + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny())) + .Returns(async (request, _) => + { + using var captureStream = new MemoryStream(); + await request.InputStream.CopyToAsync(captureStream); + uploadedPayloads.Add(captureStream.ToArray()); + attempt++; + + if (attempt == 1) + { + throw new IOException("transient upload failure"); + } + + return new PutObjectResponse(); + }); + + var service = new S3StorageService( + s3Client.Object, + Options.Create(new S3StorageOptions + { + Bucket = "tts-bucket", + AccessKey = "access-key", + SecretKey = "secret-key" + }), + Mock.Of>()); + + await using var content = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 }); + + await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); + + uploadedPayloads.Should().HaveCount(2); + uploadedPayloads[0].Should().Equal(1, 2, 3, 4); + uploadedPayloads[1].Should().Equal(1, 2, 3, 4); + s3Client.Verify(x => x.PutObjectAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + private sealed class NonSeekableReadStream : Stream + { + private readonly MemoryStream _inner; + + public NonSeekableReadStream(byte[] bytes) + { + _inner = new MemoryStream(bytes); + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _inner.Length; + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _inner.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return _inner.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inner.ReadAsync(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs new file mode 100644 index 00000000..dffdb6de --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Controllers; +using Resgrid.Web.Tts.Models; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsAdminControllerTests + { + private Mock _ttsService; + private Mock _ttsPlaybackUrlService; + private TtsOptions _options; + + [SetUp] + public void SetUp() + { + _ttsService = new Mock(MockBehavior.Strict); + _ttsPlaybackUrlService = new Mock(MockBehavior.Strict); + _ttsPlaybackUrlService + .Setup(x => x.CreatePlaybackUrl(It.IsAny(), It.IsAny())) + .Returns((_, hash) => new Uri($"https://tts.example.com/tts/audio/{hash}.wav")); + _options = new TtsOptions + { + DefaultVoice = "en-us", + DefaultSpeed = 175, + StaticPromptAdminKey = "secret-key", + PreGeneratedPrompts = new List { "Alpha", "Beta" } + }; + } + + [Test] + public async Task regenerate_static_prompts_should_reject_invalid_admin_key() + { + var controller = BuildController(); + + var result = await controller.RegenerateStaticPromptsAsync("bad-key", new StaticPromptRegenerationRequest(), CancellationToken.None); + + result.Result.Should().BeOfType(); + _ttsService.Verify(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task regenerate_static_prompts_should_use_supplied_prompts_when_authorized() + { + var responses = new[] + { + new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 175 } + }; + List capturedPrompts = null; + _ttsService + .Setup(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList()) + .ReturnsAsync(responses); + + var controller = BuildController(); + var request = new StaticPromptRegenerationRequest + { + Prompts = new List + { + new TtsRequest { Text = "Prompt 1", Voice = "en-gb", Speed = 180 } + } + }; + + var result = await controller.RegenerateStaticPromptsAsync("secret-key", request, CancellationToken.None); + + var okResult = result.Result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.PromptCount.Should().Be(1); + response.Prompts.Should().BeEquivalentTo(responses, options => options.Excluding(x => x.Url)); + response.Prompts.Single().Url.Should().Be("https://tts.example.com/tts/audio/a.wav"); + capturedPrompts.Should().ContainSingle(); + capturedPrompts![0].Text.Should().Be("Prompt 1"); + capturedPrompts[0].Voice.Should().Be("en-gb"); + capturedPrompts[0].Speed.Should().Be(180); + } + + [Test] + public async Task regenerate_static_prompts_should_fall_back_to_configured_prompts_when_request_is_empty() + { + List capturedPrompts = null; + _ttsService + .Setup(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList()) + .ReturnsAsync(new[] + { + new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 175 }, + new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us", Speed = 175 } + }); + + var controller = BuildController(); + + var result = await controller.RegenerateStaticPromptsAsync("secret-key", new StaticPromptRegenerationRequest(), CancellationToken.None); + + result.Result.Should().BeOfType(); + capturedPrompts.Should().HaveCount(2); + capturedPrompts!.Select(x => x.Text).Should().Equal("Alpha", "Beta"); + capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us"); + capturedPrompts.Select(x => x.Speed).Should().OnlyContain(x => x == 175); + } + + private TtsAdminController BuildController() + { + return new TtsAdminController(_ttsService.Object, _ttsPlaybackUrlService.Object, Options.Create(_options)) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs new file mode 100644 index 00000000..b60395ef --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Web.Tts.Configuration; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsConfigurationRegistrationTests + { + private string _originalS3AccessKey; + private string _originalS3SecretKey; + private string _originalS3Bucket; + private string _originalS3Endpoint; + private string _originalDefaultVoice; + private int _originalDefaultSpeed; + private string _originalPreGeneratedPrompts; + private string _originalPlaybackBaseUrl; + private int _originalPlaybackMemoryCacheMinutes; + private int _originalPlaybackCacheControlSeconds; + private int _originalRateLimitPermitLimit; + private string _originalTempDirectory; + private string _originalStaticPromptAdminKey; + + [SetUp] + public void SetUp() + { + _originalS3AccessKey = TtsConfig.S3AccessKey; + _originalS3SecretKey = TtsConfig.S3SecretKey; + _originalS3Bucket = TtsConfig.S3Bucket; + _originalS3Endpoint = TtsConfig.S3Endpoint; + _originalDefaultVoice = TtsConfig.DefaultVoice; + _originalDefaultSpeed = TtsConfig.DefaultSpeed; + _originalPreGeneratedPrompts = TtsConfig.PreGeneratedPrompts; + _originalPlaybackBaseUrl = TtsConfig.PlaybackBaseUrl; + _originalPlaybackMemoryCacheMinutes = TtsConfig.PlaybackMemoryCacheMinutes; + _originalPlaybackCacheControlSeconds = TtsConfig.PlaybackCacheControlSeconds; + _originalRateLimitPermitLimit = TtsConfig.RateLimitPermitLimit; + _originalTempDirectory = TtsConfig.TempDirectory; + _originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey; + } + + [TearDown] + public void TearDown() + { + TtsConfig.S3AccessKey = _originalS3AccessKey; + TtsConfig.S3SecretKey = _originalS3SecretKey; + TtsConfig.S3Bucket = _originalS3Bucket; + TtsConfig.S3Endpoint = _originalS3Endpoint; + TtsConfig.DefaultVoice = _originalDefaultVoice; + TtsConfig.DefaultSpeed = _originalDefaultSpeed; + TtsConfig.PreGeneratedPrompts = _originalPreGeneratedPrompts; + TtsConfig.PlaybackBaseUrl = _originalPlaybackBaseUrl; + TtsConfig.PlaybackMemoryCacheMinutes = _originalPlaybackMemoryCacheMinutes; + TtsConfig.PlaybackCacheControlSeconds = _originalPlaybackCacheControlSeconds; + TtsConfig.RateLimitPermitLimit = _originalRateLimitPermitLimit; + TtsConfig.TempDirectory = _originalTempDirectory; + TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey; + } + + [Test] + public void add_tts_configuration_should_map_resgrid_config_values_into_options() + { + TtsConfig.S3AccessKey = "access-key"; + TtsConfig.S3SecretKey = "secret-key"; + TtsConfig.S3Bucket = "tts-bucket"; + TtsConfig.S3Endpoint = "https://minio.example.com"; + TtsConfig.DefaultVoice = "en-gb"; + TtsConfig.DefaultSpeed = 190; + TtsConfig.PreGeneratedPrompts = "Alpha;Beta"; + TtsConfig.PlaybackBaseUrl = "https://tts.example.com"; + TtsConfig.PlaybackMemoryCacheMinutes = 90; + TtsConfig.PlaybackCacheControlSeconds = 7200; + TtsConfig.RateLimitPermitLimit = 15; + TtsConfig.TempDirectory = "/tmp/custom-tts"; + TtsConfig.StaticPromptAdminKey = "prompt-admin-key"; + + var services = new ServiceCollection(); + services.AddTtsConfiguration(); + + using var provider = services.BuildServiceProvider(); + + var s3Options = provider.GetRequiredService>().Value; + var ttsOptions = provider.GetRequiredService>().Value; + var rateLimitOptions = provider.GetRequiredService>().Value; + + s3Options.AccessKey.Should().Be("access-key"); + s3Options.SecretKey.Should().Be("secret-key"); + s3Options.Bucket.Should().Be("tts-bucket"); + s3Options.Endpoint.Should().Be("https://minio.example.com"); + ttsOptions.DefaultVoice.Should().Be("en-gb"); + ttsOptions.DefaultSpeed.Should().Be(190); + ttsOptions.PlaybackBaseUrl.Should().Be("https://tts.example.com"); + ttsOptions.PlaybackMemoryCacheMinutes.Should().Be(90); + ttsOptions.PlaybackCacheControlSeconds.Should().Be(7200); + ttsOptions.TempDirectory.Should().Be("/tmp/custom-tts"); + ttsOptions.StaticPromptAdminKey.Should().Be("prompt-admin-key"); + ttsOptions.PreGeneratedPrompts.Should().Equal("Alpha", "Beta"); + rateLimitOptions.PermitLimit.Should().Be(15); + } + + [Test] + public void add_tts_configuration_should_require_static_prompt_admin_key() + { + TtsConfig.StaticPromptAdminKey = " "; + + var services = new ServiceCollection(); + services.AddTtsConfiguration(); + + using var provider = services.BuildServiceProvider(); + + FluentActions + .Invoking(() => provider.GetRequiredService>().Value) + .Should() + .Throw() + .WithMessage("*A static prompt admin key is required.*"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs new file mode 100644 index 00000000..6095c62e --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Controllers; +using Resgrid.Web.Tts.Models; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsControllerTests + { + private Mock _ttsService; + private Mock _cacheService; + private Mock _ttsPlaybackUrlService; + private TtsController _controller; + + [SetUp] + public void SetUp() + { + _ttsService = new Mock(MockBehavior.Strict); + _cacheService = new Mock(MockBehavior.Strict); + _ttsPlaybackUrlService = new Mock(MockBehavior.Strict); + + _controller = new TtsController( + _ttsService.Object, + _cacheService.Object, + _ttsPlaybackUrlService.Object, + Options.Create(new TtsOptions + { + PlaybackCacheControlSeconds = 7200 + })) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + [Test] + public async Task generate_async_should_return_playback_api_url() + { + var serviceResponse = new TtsResponse + { + Hash = new string('a', 64), + ObjectKey = "tts/audio.wav", + Url = "https://s3.example.com/tts/audio.wav", + Voice = "en-us", + Speed = 175 + }; + + _ttsService + .Setup(x => x.GenerateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(serviceResponse); + _ttsPlaybackUrlService + .Setup(x => x.CreatePlaybackUrl(It.IsAny(), serviceResponse.Hash)) + .Returns(new Uri($"https://tts.example.com/tts/audio/{serviceResponse.Hash}.wav")); + + var result = await _controller.GenerateAsync(new TtsRequest { Text = "Hello" }, CancellationToken.None); + + var okResult = result.Result.Should().BeOfType().Subject; + var response = okResult.Value.Should().BeOfType().Subject; + response.Url.Should().Be($"https://tts.example.com/tts/audio/{serviceResponse.Hash}.wav"); + } + + [Test] + public async Task get_audio_async_should_return_cached_audio_with_cache_headers() + { + var hash = new string('b', 64); + var audio = new TtsAudioContent( + new byte[] { 1, 2, 3 }, + "audio/wav", + "\"etag-123\"", + new DateTimeOffset(2026, 4, 22, 12, 0, 0, TimeSpan.Zero)); + + _cacheService + .Setup(x => x.TryGetAudioAsync(hash, It.IsAny())) + .ReturnsAsync(audio); + + var result = await _controller.GetAudioAsync(hash, CancellationToken.None); + + var fileResult = result.Should().BeOfType().Subject; + fileResult.FileContents.Should().Equal(audio.AudioBytes); + fileResult.ContentType.Should().Be("audio/wav"); + fileResult.EnableRangeProcessing.Should().BeTrue(); + fileResult.EntityTag.Should().NotBeNull(); + fileResult.EntityTag!.ToString().Should().Be("\"etag-123\""); + fileResult.LastModified.Should().Be(audio.LastModified); + _controller.Response.Headers["Cache-Control"].ToString().Should().Be("public,max-age=7200,immutable"); + } + + [Test] + public async Task get_audio_async_should_return_not_found_when_audio_missing() + { + var hash = new string('c', 64); + + _cacheService + .Setup(x => x.TryGetAudioAsync(hash, It.IsAny())) + .ReturnsAsync((TtsAudioContent)null); + + var result = await _controller.GetAudioAsync(hash, CancellationToken.None); + + result.Should().BeOfType(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs new file mode 100644 index 00000000..eea8df15 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs @@ -0,0 +1,58 @@ +using System; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsPlaybackUrlServiceTests + { + [Test] + public void create_playback_url_should_normalize_and_escape_hash() + { + var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions + { + PlaybackBaseUrl = "https://tts.example.com" + })); + + var result = service.CreatePlaybackUrl(null, " Ab C "); + + result.Should().Be(new Uri("https://tts.example.com/tts/audio/ab%20c.wav")); + } + + [TestCase("abc/123")] + [TestCase("abc?123")] + [TestCase("abc#123")] + public void create_playback_url_should_reject_hashes_with_url_delimiters(string hash) + { + var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions + { + PlaybackBaseUrl = "https://tts.example.com" + })); + + FluentActions + .Invoking(() => service.CreatePlaybackUrl(null, hash)) + .Should() + .Throw() + .WithParameterName("hash"); + } + + [Test] + public void create_playback_url_should_use_request_base_when_configured_base_url_missing() + { + var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions())); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("api.example.com"); + httpContext.Request.PathBase = new PathString("/tts-service"); + + var result = service.CreatePlaybackUrl(httpContext.Request, "ABC123"); + + result.Should().Be(new Uri("https://api.example.com/tts-service/tts/audio/abc123.wav")); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs new file mode 100644 index 00000000..a7d9774f --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Net; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Web.Tts.Configuration; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsRequestIdentityTests + { + private string _originalIngressProxyNetwork; + private int _originalIngressProxyNetworkCidr; + + [SetUp] + public void SetUp() + { + _originalIngressProxyNetwork = WebConfig.IngressProxyNetwork; + _originalIngressProxyNetworkCidr = WebConfig.IngressProxyNetworkCidr; + } + + [TearDown] + public void TearDown() + { + WebConfig.IngressProxyNetwork = _originalIngressProxyNetwork; + WebConfig.IngressProxyNetworkCidr = _originalIngressProxyNetworkCidr; + } + + [Test] + public void configure_forwarded_headers_should_register_configured_ingress_proxy_network() + { + WebConfig.IngressProxyNetwork = "10.42.0.0"; + WebConfig.IngressProxyNetworkCidr = 16; + var configureMethod = typeof(TtsRequestIdentity) + .GetMethod(nameof(TtsRequestIdentity.ConfigureForwardedHeaders), BindingFlags.Public | BindingFlags.Static)!; + var optionsType = configureMethod.GetParameters()[0].ParameterType; + var options = Activator.CreateInstance(optionsType!); + + configureMethod.Invoke(null, new object[] { options! }); + + optionsType!.GetProperty("ForwardedHeaders")!.GetValue(options)!.ToString().Should().Be("XForwardedFor, XForwardedProto"); + + var knownNetworks = ((System.Collections.IEnumerable)optionsType.GetProperty("KnownNetworks")!.GetValue(options)!) + .Cast() + .Select(x => new + { + Prefix = (IPAddress)x.GetType().GetProperty("Prefix")!.GetValue(x)!, + PrefixLength = (int)x.GetType().GetProperty("PrefixLength")!.GetValue(x)! + }) + .ToList(); + + knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("10.42.0.0")) && x.PrefixLength == 16); + knownNetworks.Should().Contain(x => x.Prefix.Equals(IPAddress.Parse("::ffff:10.42.0.0")) && x.PrefixLength == 16); + } + + [Test] + public void resolve_rate_limit_partition_key_should_use_normalized_client_ip() + { + var httpContext = new DefaultHttpContext(); + httpContext.Connection.RemoteIpAddress = IPAddress.Parse("::ffff:203.0.113.10"); + + var clientId = TtsRequestIdentity.ResolveRateLimitPartitionKey(httpContext); + + clientId.Should().Be("203.0.113.10"); + } + + [Test] + public void resolve_rate_limit_partition_key_should_fall_back_to_unknown_without_client_ip() + { + var httpContext = new DefaultHttpContext(); + + var clientId = TtsRequestIdentity.ResolveRateLimitPartitionKey(httpContext); + + clientId.Should().Be("unknown"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs new file mode 100644 index 00000000..de81c5fa --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Models; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class TtsServiceTests + { + private static readonly TtsCacheKey CacheKey = new("abc123", "tts/abc123.wav"); + + private Mock _cacheService; + private Mock _audioProcessingService; + private TtsService _service; + + [SetUp] + public void SetUp() + { + _cacheService = new Mock(MockBehavior.Strict); + _audioProcessingService = new Mock(MockBehavior.Strict); + + _service = new TtsService( + _cacheService.Object, + _audioProcessingService.Object, + Options.Create(new TtsOptions + { + DefaultVoice = "en-us", + DefaultSpeed = 175, + MaxConcurrentGenerations = 2, + MaxTextLength = 500 + }), + Mock.Of>()); + } + + [Test] + public async Task generate_async_should_return_cached_response_without_generating_audio() + { + var cachedUri = new Uri("https://cdn.example.com/tts/abc123.wav"); + + _cacheService + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us", 175)) + .Returns(CacheKey); + _cacheService + .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) + .ReturnsAsync(cachedUri); + + var result = await _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes" }, CancellationToken.None); + + result.Cached.Should().BeTrue(); + result.Hash.Should().Be(CacheKey.Hash); + result.ObjectKey.Should().Be(CacheKey.ObjectKey); + result.Url.Should().Be(cachedUri.ToString()); + result.Voice.Should().Be("en-us"); + result.Speed.Should().Be(175); + + _audioProcessingService.Verify( + x => x.GenerateNormalizedWavAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _cacheService.Verify( + x => x.StoreAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task generate_async_should_generate_and_store_audio_when_cache_misses() + { + var audioBytes = new byte[] { 1, 2, 3, 4 }; + var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav"); + + _cacheService + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us", 175)) + .Returns(CacheKey); + _cacheService + .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) + .ReturnsAsync((Uri)null) + .ReturnsAsync((Uri)null); + _audioProcessingService + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us", 175, It.IsAny())) + .ReturnsAsync(audioBytes); + _cacheService + .Setup(x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny())) + .ReturnsAsync(objectUri); + + var result = await _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes" }, CancellationToken.None); + + result.Cached.Should().BeFalse(); + result.Url.Should().Be(objectUri.ToString()); + result.Voice.Should().Be("en-us"); + result.Speed.Should().Be(175); + + _audioProcessingService.Verify( + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us", 175, It.IsAny()), + Times.Once); + _cacheService.Verify( + x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), + Times.Once); + } + + [Test] + public async Task generate_async_should_reject_blank_text() + { + Func action = async () => await _service.GenerateAsync(new TtsRequest { Text = " " }, CancellationToken.None); + + await action.Should().ThrowAsync() + .WithMessage("*Text is required*"); + } + + [Test] + public async Task generate_async_should_deduplicate_concurrent_generation_for_the_same_cache_key() + { + var audioBytes = new byte[] { 1, 2, 3, 4 }; + var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav"); + var generationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowGenerationCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cacheLookupCount = 0; + + _cacheService + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us", 175)) + .Returns(CacheKey); + _cacheService + .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) + .Returns(() => + { + var attempt = Interlocked.Increment(ref cacheLookupCount); + return Task.FromResult(attempt < 4 ? null : objectUri); + }); + _audioProcessingService + .Setup(x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us", 175, It.IsAny())) + .Returns(async () => + { + generationStarted.TrySetResult(true); + await allowGenerationCompletion.Task; + return audioBytes; + }); + _cacheService + .Setup(x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny())) + .ReturnsAsync(objectUri); + + var firstRequest = _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes" }, CancellationToken.None); + await generationStarted.Task; + var secondRequest = _service.GenerateAsync(new TtsRequest { Text = "Press 1 for yes" }, CancellationToken.None); + + await Task.Yield(); + allowGenerationCompletion.TrySetResult(true); + + var responses = await Task.WhenAll(firstRequest, secondRequest); + + responses.Should().HaveCount(2); + responses.Count(response => response.Cached).Should().Be(1); + responses.Count(response => !response.Cached).Should().Be(1); + _audioProcessingService.Verify( + x => x.GenerateNormalizedWavAsync("Press 1 for yes", "en-us", 175, It.IsAny()), + Times.Once); + _cacheService.Verify( + x => x.StoreAsync(CacheKey, It.Is(bytes => bytes.SequenceEqual(audioBytes)), It.IsAny()), + Times.Once); + _cacheService.Verify( + x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny()), + Times.Exactly(4)); + } + } +} diff --git a/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs new file mode 100644 index 00000000..225388ba --- /dev/null +++ b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Quidjibo.Misc; +using Resgrid.Config; +using Resgrid.Model.Services; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Console.Tasks; + +namespace Resgrid.Tests.Workers.Console.Tasks +{ + [TestFixture] + public class TtsStaticPromptRefreshTaskTests + { + private static readonly FieldInfo WorkerBootstrapperContainerField = typeof(Resgrid.Workers.Framework.Bootstrapper) + .GetField("_container", BindingFlags.Static | BindingFlags.NonPublic)!; + + private IContainer _originalWorkerContainer; + private IContainer _testWorkerContainer; + private string _originalServiceBaseUrl; + private string _originalStaticPromptAdminKey; + + [SetUp] + public void SetUp() + { + _originalWorkerContainer = WorkerBootstrapperContainerField.GetValue(null) as IContainer; + _originalServiceBaseUrl = TtsConfig.ServiceBaseUrl; + _originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey; + + TtsConfig.ServiceBaseUrl = "https://tts.example.com"; + TtsConfig.StaticPromptAdminKey = "prompt-admin-key"; + } + + [TearDown] + public void TearDown() + { + WorkerBootstrapperContainerField.SetValue(null, _originalWorkerContainer); + _testWorkerContainer?.Dispose(); + TtsConfig.ServiceBaseUrl = _originalServiceBaseUrl; + TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey; + } + + [Test] + public async Task process_async_should_rethrow_refresh_failures() + { + var failure = new InvalidOperationException("refresh failed"); + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.RegenerateStaticPromptsAsync(It.IsAny>(), It.IsAny())) + .ThrowsAsync(failure); + SetWorkerContainer(ttsAudioService.Object); + + var task = new TtsStaticPromptRefreshTask(Mock.Of()); + var progress = new Mock(MockBehavior.Loose); + + await FluentActions + .Awaiting(() => task.ProcessAsync(new TtsStaticPromptRefreshCommand(1), progress.Object, CancellationToken.None)) + .Should() + .ThrowAsync() + .WithMessage("refresh failed"); + } + + [Test] + public async Task process_async_should_rethrow_cancellation() + { + using var cancellationTokenSource = new CancellationTokenSource(); + var ttsAudioService = new Mock(MockBehavior.Strict); + ttsAudioService + .Setup(x => x.RegenerateStaticPromptsAsync(It.IsAny>(), It.Is(token => token == cancellationTokenSource.Token))) + .ThrowsAsync(new OperationCanceledException(cancellationTokenSource.Token)); + SetWorkerContainer(ttsAudioService.Object); + + var task = new TtsStaticPromptRefreshTask(Mock.Of()); + var progress = new Mock(MockBehavior.Loose); + + await FluentActions + .Awaiting(() => task.ProcessAsync(new TtsStaticPromptRefreshCommand(1), progress.Object, cancellationTokenSource.Token)) + .Should() + .ThrowAsync(); + } + + private void SetWorkerContainer(ITtsAudioService ttsAudioService) + { + _testWorkerContainer?.Dispose(); + + var builder = new ContainerBuilder(); + builder.RegisterInstance(ttsAudioService).As(); + _testWorkerContainer = builder.Build(); + + WorkerBootstrapperContainerField.SetValue(null, _testWorkerContainer); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 57ffeb96..b762e13f 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -1,9 +1,11 @@ -using System; +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -14,6 +16,7 @@ using Resgrid.Model.Queue; using Resgrid.Model.Services; using Resgrid.Web.Services.Models; +using Resgrid.Web.Services.Twilio; using Twilio.AspNet.Common; using Twilio.AspNet.Core; using Twilio.TwiML; @@ -48,6 +51,7 @@ public class TwilioController : ControllerBase private readonly ICalendarService _calendarService; private readonly ICommunicationTestService _communicationTestService; private readonly IEncryptionService _encryptionService; + private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, @@ -55,7 +59,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService, - IEncryptionService encryptionService) + IEncryptionService encryptionService, ITwilioVoiceResponseService twilioVoiceResponseService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -76,6 +80,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN _calendarService = calendarService; _communicationTestService = communicationTestService; _encryptionService = encryptionService; + _twilioVoiceResponseService = twilioVoiceResponseService; } #endregion Private Readonly Properties and Constructors @@ -473,14 +478,16 @@ public async Task VoiceCall(string userId, int callId) if (call == null) { - response.Say("This call has been closed. Goodbye.").Hangup(); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); + return CreateVoiceContentResult(response); } if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) { - response.Say(string.Format("This call, Id {0} has been closed. Goodbye.", call.Number)).Hangup(); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); + return CreateVoiceContentResult(response); } var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); @@ -515,19 +522,15 @@ public async Task VoiceCall(string userId, int callId) { BargeIn = true }; - gatherResponse1.Append(new Say(sb1.ToString())); + await AppendVoicePromptAsync(gatherResponse1, TwilioVoicePromptCatalog.RepeatAndRespondToScene, call.DepartmentId); + await AppendVoicePromptsAsync(gatherResponse1, BuildStationOptionPrompts(stations), call.DepartmentId); gatherResponse1.Append(playResponse); response.Append(gatherResponse1); } response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } } @@ -558,34 +561,21 @@ public async Task VoiceCall(string userId, int callId) else sb.Append(string.Format("{0}, Priority {1} Nature {2}", call.Name, call.GetPriorityText(), call.NatureOfCall)); - sb.Append(", Press 0 to repeat, Press 1 to respond to the scene"); - - for (int i = 0; i < stations.Count; i++) - { - if (i >= 8) - break; - - sb.Append(string.Format(", press {0} to respond to {1}", i + 2, stations[i].Name)); - } - for (int repeat = 0; repeat < 2; repeat++) { var gatherResponse = new Gather(numDigits: 1, action: new Uri(string.Format("{0}/api/Twilio/VoiceCallAction?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), method: "GET") { BargeIn = true }; - gatherResponse.Append(new Say(sb.ToString())); + await AppendVoicePromptAsync(gatherResponse, sb.ToString(), call.DepartmentId); + await AppendVoicePromptAsync(gatherResponse, TwilioVoicePromptCatalog.RepeatAndRespondToScene, call.DepartmentId); + await AppendVoicePromptsAsync(gatherResponse, BuildStationOptionPrompts(stations), call.DepartmentId); response.Append(gatherResponse); } response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("VoiceCallAction")] @@ -603,7 +593,8 @@ public async Task VoiceCallAction(string userId, int callId, [From var call = await _callsService.GetCallByIdAsync(callId); await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); - response.Say("You have been marked responding to the scene, goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToScene, call.DepartmentId); + response.Hangup(); } else if (int.TryParse(twilioRequest.Digits, out var digit)) { @@ -621,21 +612,19 @@ public async Task VoiceCallAction(string userId, int callId, [From await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId); - response.Say(string.Format("You have been marked responding to {0}, goodbye.", station.Name)).Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); + response.Hangup(); } } } else { - response.Say("Sorry, that was not a valid selection.").Redirect(new Uri(string.Format("{0}/api/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET"); + var call = await _callsService.GetCallByIdAsync(callId); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidSelection, call?.DepartmentId); + response.Redirect(new Uri(string.Format("{0}/api/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET"); } - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("VoiceVerification")] @@ -644,11 +633,11 @@ await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)Acti public async Task VoiceVerification(string userId, int contactType) { if (string.IsNullOrWhiteSpace(userId)) - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); var profile = await _userProfileService.GetProfileByUserIdAsync(userId, bypassCache: true); if (profile == null) - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); string encryptedCode; DateTime? expiry; @@ -666,11 +655,11 @@ public async Task VoiceVerification(string userId, int contactType alreadyConsumed = profile.HomeVerificationVoiceCodeConsumed; break; default: - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); } if (alreadyConsumed || string.IsNullOrWhiteSpace(encryptedCode) || !expiry.HasValue || DateTime.UtcNow > expiry.Value) - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); string code; try @@ -680,15 +669,15 @@ public async Task VoiceVerification(string userId, int contactType catch (CryptographicException ex) { Framework.Logging.LogException(ex); - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); } if (string.IsNullOrWhiteSpace(code)) - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); var department = await _departmentsService.GetDepartmentByUserIdAsync(profile.UserId, false); if (department == null) - return GetVoiceVerificationErrorResult(); + return await GetVoiceVerificationErrorResult(); switch ((ContactVerificationType)contactType) { @@ -705,22 +694,17 @@ public async Task VoiceVerification(string userId, int contactType var response = new VoiceResponse(); var spokenCode = string.Join(", ", code.ToCharArray()); - response.Say("Hello, this is Resgrid calling with your verification code."); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationGreeting, department.DepartmentId); for (int i = 0; i < 3; i++) { response.Pause(length: 1); - response.Say($"Your verification code is: {spokenCode}."); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationCode(spokenCode), department.DepartmentId); } response.Pause(length: 1); - response.Say("That was your Resgrid verification code. Goodbye."); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VerificationClosing, department.DepartmentId); response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("InboundVoice")] @@ -749,69 +733,48 @@ public async Task InboundVoice([FromQuery] TwilioGatherRequest req request.From.Replace("+", ""); if (authroized) { - StringBuilder sb = new StringBuilder(); - sb.Append($@"Hello {profile.FirstName}, this is the Resgrid automated voice system for {department.Name}. Please select from the following options. - To list current active calls, press 1. - To list current user statuses, press 2. - To list current unit statuses, press 3. - To list upcoming calendar events, press 4. - To list upcoming shifts, press 5. - To set your current status, press 6. - To set your current staffing level, press 7."); - for (int repeat = 0; repeat < 2; repeat++) { var gatherResponse = new Gather(numDigits: 1, action: new Uri(string.Format("{0}/api/Twilio/InboundVoiceAction?userId={1}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, profile.UserId)), method: "GET") { BargeIn = true }; - gatherResponse.Append(new Say(sb.ToString())); + await AppendVoicePromptsAsync(gatherResponse, BuildMainMenuPrompts(profile.FirstName, department.Name), department.DepartmentId); response.Append(gatherResponse); } response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } else { - response.Say("Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable, department.DepartmentId); + response.Hangup(); } } else { - response.Say("Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable); + response.Hangup(); } } else { - response.Say("Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable); + response.Hangup(); } - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } - private ContentResult GetVoiceVerificationErrorResult() + private async Task GetVoiceVerificationErrorResult() { var response = new VoiceResponse(); - response.Say("We couldn't complete your verification call. Please request a new code and try again. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.VoiceVerificationFailure); + response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("InboundVoiceAction")] @@ -823,19 +786,12 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var department = await _departmentsService.GetDepartmentByUserIdAsync(userId); var profile = await _userProfileService.GetProfileByUserIdAsync(userId); - StringBuilder sb = new StringBuilder(); + var prompts = new List(); Gather gatherResponse = null; if (twilioRequest.Digits == "0") { - sb.Append($@"Hello {profile.FirstName}, this is the Resgrid automated voice system for {department.Name}. Please select from the following options. - To list current active calls, press 1. - To list current user statuses, press 2. - To list current unit statuses, press 3. - To list upcoming calendar events, press 4. - To list upcoming shifts, press 5. - To set your current status, press 6. - To set your current staffing level, press 7."); + prompts.AddRange(BuildMainMenuPrompts(profile.FirstName, department.Name)); } else if (twilioRequest.Digits == "1") { @@ -843,16 +799,16 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo if (calls != null && calls.Any()) { - sb.Append($"There are {calls.Count()} active calls for department {department.Name}."); + prompts.Add($"There are {calls.Count()} active calls for department {department.Name}."); foreach (var call in calls) { - sb.Append($"{call.Name}, Priority {call.GetPriorityText()} Address {call.Address} Nature {StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall)}."); + prompts.Add($"{call.Name}, Priority {call.GetPriorityText()} Address {call.Address} Nature {StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall)}."); } } else { - sb.Append($"There are no active calls for department {department.Name}."); + prompts.Add($"There are no active calls for department {department.Name}."); } } else if (twilioRequest.Digits == "2") @@ -870,7 +826,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var staffingLevel = await _customStateService.GetCustomPersonnelStaffingAsync(department.DepartmentId, userState); var status = await _customStateService.GetCustomPersonnelStatusAsync(department.DepartmentId, lastActionLog); - sb.Append($"{user.LastName}, {user.FirstName}, Status {status.ButtonText} Staffing Level {staffingLevel.ButtonText}."); + prompts.Add($"{user.LastName}, {user.FirstName}, Status {status.ButtonText} Staffing Level {staffingLevel.ButtonText}."); } } } @@ -887,12 +843,12 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var unitState = states.FirstOrDefault(x => x.UnitId == unit.UnitId); var unitStatus = await _customStateService.GetCustomUnitStateAsync(unitState); - sb.Append($"{unit.Name}, Status {unitStatus.ButtonText}."); + prompts.Add($"{unit.Name}, Status {unitStatus.ButtonText}."); } } else { - sb.Append($"There are no units for department {department.Name}."); + prompts.Add($"There are no units for department {department.Name}."); } } else if (twilioRequest.Digits == "4") @@ -903,31 +859,31 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo { foreach (var item in upcomingItems) { - sb.Append($"{item.Title}, {item.Start.TimeConverter(department).ToShortDateString()}, {item.Start.TimeConverter(department).ToShortTimeString()}, {item.Location}"); + prompts.Add($"{item.Title}, {item.Start.TimeConverter(department).ToShortDateString()}, {item.Start.TimeConverter(department).ToShortTimeString()}, {item.Location}"); } } else { - sb.Append($"There are no upcoming Calendar events for department {department.Name}."); + prompts.Add($"There are no upcoming Calendar events for department {department.Name}."); } } else if (twilioRequest.Digits == "5") { - sb.Append($"There are no upcoming shifts for department {department.Name}."); + prompts.Add($"There are no upcoming shifts for department {department.Name}."); } else if (twilioRequest.Digits == "6") // Set current status { var options = await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(department.DepartmentId); int index = 1; - sb.Append($"To set your Current Status please select from the following options."); + prompts.Add(TwilioVoicePromptCatalog.StatusSelectionIntro); foreach (var option in options) { if (option.CustomStateDetailId == 0 || option.IsDeleted) continue; - sb.Append($"Press {index} for {option.ButtonText}."); + prompts.Add(TwilioVoicePromptCatalog.StatusOption(index, option.ButtonText)); index++; } @@ -941,14 +897,14 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var options = await _customStateService.GetCustomPersonnelStaffingsOrDefaultsAsync(department.DepartmentId); int index = 1; - sb.Append($"To set your Current Staffing please select from the following options."); + prompts.Add(TwilioVoicePromptCatalog.StaffingSelectionIntro); foreach (var option in options) { if (option.CustomStateDetailId == 0 || option.IsDeleted) continue; - sb.Append($"Press {index} for {option.ButtonText}."); + prompts.Add(TwilioVoicePromptCatalog.StaffingOption(index, option.ButtonText)); index++; } @@ -972,18 +928,14 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo { BargeIn = true }; - gather.Append(new Say(sb.ToString() + " Press 0 to go back to the main menu.")); + await AppendVoicePromptsAsync(gather, prompts, department.DepartmentId); + await AppendVoicePromptAsync(gather, TwilioVoicePromptCatalog.GoBackToMainMenu, department.DepartmentId); response.Append(gather); } response.Hangup(); - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("InboundVoiceActionStatus")] @@ -1009,25 +961,23 @@ public async Task InboundVoiceActionStatus(string userId, [FromQue if (selectedOption != null && selectedOption.CustomStateDetailId > 0 && !selectedOption.IsDeleted) { await _actionLogsService.SetUserActionAsync(userId, department.DepartmentId, selectedOption.CustomStateDetailId); - response.Say($"You have been marked as {selectedOption.ButtonText}, goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.StatusMarked(selectedOption.ButtonText), department.DepartmentId); + response.Hangup(); } } else { - response.Say("Invalid status selection, goodbye.").Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoice"), "GET"); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidStatusSelection, department.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); } } else { - response.Say("No status selection made, goodbye.").Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoice"), "GET"); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.NoStatusSelection, department.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); } - return new ContentResult - { - Content = response.ToString(), - ContentType = "application/xml", - StatusCode = 200 - }; + return CreateVoiceContentResult(response); } [HttpGet("InboundVoiceActionStaffing")] @@ -1049,20 +999,68 @@ public async Task InboundVoiceActionStaffing(string userId, [FromQ { var selectedOption = activeOptions[digit - 1]; await _userStateService.CreateUserState(userId, department.DepartmentId, selectedOption.CustomStateDetailId); - response.Say($"You have been marked as {selectedOption.ButtonText}. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.StaffingMarked(selectedOption.ButtonText), department.DepartmentId); + response.Hangup(); } else { - response.Say("Invalid staffing selection. Returning to the main menu.") - .Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InvalidStaffingSelection, department.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); } } else { - response.Say("No staffing selection made. Returning to the main menu.") - .Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.NoStaffingSelection, department.DepartmentId); + response.Redirect(new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"), "GET"); } + return CreateVoiceContentResult(response); + } + + private async System.Threading.Tasks.Task AppendVoicePromptAsync(VoiceResponse response, string text, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptAsync(response, text, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async System.Threading.Tasks.Task AppendVoicePromptAsync(Gather gather, string text, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptAsync(gather, text, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async System.Threading.Tasks.Task AppendVoicePromptsAsync(VoiceResponse response, IEnumerable prompts, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptsAsync(response, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async System.Threading.Tasks.Task AppendVoicePromptsAsync(Gather gather, IEnumerable prompts, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptsAsync(gather, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async Task GetDepartmentTtsLanguageAsync(int? departmentId) + { + if (!departmentId.HasValue || departmentId.Value <= 0) + return null; + + var cacheKey = $"twilio-tts-language:{departmentId.Value}"; + + if (HttpContext?.Items != null && HttpContext.Items.TryGetValue(cacheKey, out var cachedLanguage)) + return cachedLanguage as string; + + var ttsLanguage = await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(departmentId.Value); + + if (HttpContext?.Items != null) + HttpContext.Items[cacheKey] = ttsLanguage; + + return ttsLanguage; + } + + private static ContentResult CreateVoiceContentResult(VoiceResponse response) + { return new ContentResult { Content = response.ToString(), @@ -1070,6 +1068,36 @@ public async Task InboundVoiceActionStaffing(string userId, [FromQ StatusCode = 200 }; } + + private static IReadOnlyCollection BuildStationOptionPrompts(IEnumerable stations) + { + var prompts = new List(); + var index = 2; + + foreach (var station in stations.Take(8)) + { + prompts.Add(TwilioVoicePromptCatalog.RespondToStationOption(index, station.Name)); + index++; + } + + return prompts; + } + + private static IReadOnlyCollection BuildMainMenuPrompts(string firstName, string departmentName) + { + return new[] + { + TwilioVoicePromptCatalog.MainMenuGreeting(firstName, departmentName), + TwilioVoicePromptCatalog.MainMenuSelectionIntro, + TwilioVoicePromptCatalog.MainMenuActiveCalls, + TwilioVoicePromptCatalog.MainMenuUserStatuses, + TwilioVoicePromptCatalog.MainMenuUnitStatuses, + TwilioVoicePromptCatalog.MainMenuCalendarEvents, + TwilioVoicePromptCatalog.MainMenuShifts, + TwilioVoicePromptCatalog.MainMenuSetStatus, + TwilioVoicePromptCatalog.MainMenuSetStaffing + }; + } } [Serializable] diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs index fed9ca97..6e3526e2 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioProviderController.cs @@ -1,8 +1,10 @@ -using System; +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; @@ -13,8 +15,10 @@ using Resgrid.Model.Queue; using Resgrid.Model.Services; using Resgrid.Web.Services.Models; +using Resgrid.Web.Services.Twilio; using Twilio.AspNet.Common; using Twilio.TwiML; +using Twilio.TwiML.Voice; namespace Resgrid.Web.Services.Controllers { @@ -38,13 +42,14 @@ public class TwilioProviderController : ControllerBase private readonly ICustomStateService _customStateService; private readonly IUnitsService _unitsService; private readonly ICommunicationTestService _communicationTestService; + private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; public TwilioProviderController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, - ICommunicationTestService communicationTestService) + ICommunicationTestService communicationTestService, ITwilioVoiceResponseService twilioVoiceResponseService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -62,6 +67,7 @@ public TwilioProviderController(IDepartmentSettingsService departmentSettingsSer _customStateService = customStateService; _unitsService = unitsService; _communicationTestService = communicationTestService; + _twilioVoiceResponseService = twilioVoiceResponseService; } #endregion Private Readonly Properties and Constructors @@ -432,16 +438,18 @@ public async Task VoiceCall(string userId, int callId) if (call == null) { - response.Say("This call has been closed. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosed); + response.Hangup(); //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + return CreateVoiceContentResult(response); } if (call.State == (int)CallStates.Cancelled || call.State == (int)CallStates.Closed || call.IsDeleted) { - response.Say(string.Format("This call, Id {0} has been closed. Goodbye.", call.Number)).Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.CallClosedByNumber(call.Number), call.DepartmentId); + response.Hangup(); //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + return CreateVoiceContentResult(response); } var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId); @@ -464,29 +472,32 @@ public async Task VoiceCall(string userId, int callId) if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address)) address = call.Address; - StringBuilder sb = new StringBuilder(); - - if (!String.IsNullOrWhiteSpace(address)) - sb.Append(string.Format("{0}, Priority {1} Address {2} Nature {3}", call.Name, call.GetPriorityText(), call.Address, call.NatureOfCall)); - else - sb.Append(string.Format("{0}, Priority {1} Nature {2}", call.Name, call.GetPriorityText(), call.NatureOfCall)); - - - sb.Append(", Press 0 to repeat, Press 1 to respond to the scene"); + var prompts = new List + { + !String.IsNullOrWhiteSpace(address) + ? $"{call.Name}, Priority {call.GetPriorityText()} Address {call.Address} Nature {call.NatureOfCall}" + : $"{call.Name}, Priority {call.GetPriorityText()} Nature {call.NatureOfCall}", + TwilioVoicePromptCatalog.RepeatAndRespondToScene + }; - for (int i = 0; i < stations.Count; i++) + for (int i = 0; i < stations.Count && i < 8; i++) { - if (i >= 8) - break; + prompts.Add(TwilioVoicePromptCatalog.RespondToStationOption(i + 2, stations[i].Name)); + } - sb.Append(string.Format(", press {0} to respond to {1}", i + 2, stations[i].Name)); + for (int repeat = 0; repeat < 2; repeat++) + { + var gather = new Gather(numDigits: 1, action: new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/TwilioProvider/VoiceCallAction/{userId}/{callId}"), method: "GET") + { + BargeIn = true + }; + await AppendVoicePromptsAsync(gather, prompts, call.DepartmentId); + response.Append(gather); } - // TODO: FIIIIX - //response.Gather(new { numDigits = 1, timeout = 10, method = "GET", action = string.Format("{0}/Twilio/VoiceCallAction/{1}/{2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId) }).Say(sb.ToString()).EndGather().Pause(10).Hangup(); + response.Hangup(); - //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + return CreateVoiceContentResult(response); } [HttpGet] @@ -501,7 +512,8 @@ public async Task VoiceCallAction(string userId, int callId, [From var call = await _callsService.GetCallByIdAsync(callId); await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToScene, null, call.CallId); - response.Say("You have been marked responding to the scene, goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToScene, call.DepartmentId); + response.Hangup(); } else { @@ -519,14 +531,15 @@ public async Task VoiceCallAction(string userId, int callId, [From await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)ActionTypes.RespondingToStation, null, station.DepartmentGroupId); - response.Say(string.Format("You have been marked responding to {0}, goodbye.", station.Name)).Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.RespondingToStation(station.Name), call.DepartmentId); + response.Hangup(); } } } //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + return CreateVoiceContentResult(response); } [HttpGet] @@ -556,33 +569,83 @@ public async Task InboundVoice([FromQuery]TwilioGatherRequest requ if (department != null && profile != null) { - StringBuilder sb = new StringBuilder(); - sb.Append($@"Hello {profile.FirstName}, this is the Automated Voice System for {department.Name}. Please select from the following options. - To list current active calls press 1, - To list current user statuses press 2, - To list current unit statuses press 3, - To list upcoming Calendar events press 4, - To list upcoming Shifts press 5"); - - response.Say(sb.ToString()); + await AppendVoicePromptsAsync(response, new[] + { + TwilioVoicePromptCatalog.MainMenuGreeting(profile.FirstName, department.Name), + TwilioVoicePromptCatalog.MainMenuSelectionIntro, + TwilioVoicePromptCatalog.MainMenuActiveCalls, + TwilioVoicePromptCatalog.MainMenuUserStatuses, + TwilioVoicePromptCatalog.MainMenuUnitStatuses, + TwilioVoicePromptCatalog.MainMenuCalendarEvents, + TwilioVoicePromptCatalog.MainMenuShifts + }, department.DepartmentId); } else { - response.Say("Thank you for calling Raesgrid, the only complete software solution for first responders, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable, departmentId.Value); + response.Hangup(); } } else { - response.Say("Thank you for calling Raesgrid, the only complete software solution for first responders, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable, departmentId.Value); + response.Hangup(); } } else { - response.Say("Thank you for calling Raesgrid, the only complete software solution for first responders, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.").Hangup(); + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.InboundVoiceUnavailable); + response.Hangup(); } //return Request.CreateResponse(HttpStatusCode.OK, response.Element, new XmlMediaTypeFormatter()); - return Ok(new StringContent(response.ToString(), Encoding.UTF8, "application/xml")); + return CreateVoiceContentResult(response); + } + + private async System.Threading.Tasks.Task AppendVoicePromptAsync(VoiceResponse response, string text, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptAsync(response, text, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async System.Threading.Tasks.Task AppendVoicePromptsAsync(VoiceResponse response, IEnumerable prompts, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptsAsync(response, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async System.Threading.Tasks.Task AppendVoicePromptsAsync(Gather gather, IEnumerable prompts, int? departmentId = null) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + await _twilioVoiceResponseService.AppendPromptsAsync(gather, prompts, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + } + + private async Task GetDepartmentTtsLanguageAsync(int? departmentId) + { + if (!departmentId.HasValue || departmentId.Value <= 0) + return null; + + var cacheKey = $"twilio-provider-tts-language:{departmentId.Value}"; + + if (HttpContext?.Items != null && HttpContext.Items.TryGetValue(cacheKey, out var cachedLanguage)) + return cachedLanguage as string; + + var ttsLanguage = await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(departmentId.Value); + + if (HttpContext?.Items != null) + HttpContext.Items[cacheKey] = ttsLanguage; + + return ttsLanguage; + } + + private static ContentResult CreateVoiceContentResult(VoiceResponse response) + { + return new ContentResult + { + Content = response.ToString(), + ContentType = "application/xml", + StatusCode = 200 + }; } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs index fd5e6394..1191d423 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs @@ -1,8 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; using Resgrid.Model.Services; +using Resgrid.Web.Services.Twilio; +using System.Threading; using System.Threading.Tasks; +using Twilio.TwiML; namespace Resgrid.Web.Services.Controllers.v4 { @@ -15,10 +19,17 @@ namespace Resgrid.Web.Services.Controllers.v4 public class CommunicationTestResponseController : V4AuthenticatedApiControllerbase { private readonly ICommunicationTestService _communicationTestService; + private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; - public CommunicationTestResponseController(ICommunicationTestService communicationTestService) + public CommunicationTestResponseController( + ICommunicationTestService communicationTestService, + IDepartmentSettingsService departmentSettingsService, + ITwilioVoiceResponseService twilioVoiceResponseService) { _communicationTestService = communicationTestService; + _departmentSettingsService = departmentSettingsService; + _twilioVoiceResponseService = twilioVoiceResponseService; } /// @@ -66,12 +77,32 @@ public async Task VoiceWebhook(string token, string Digits) await _communicationTestService.RecordVoiceResponseAsync(token); } + var response = new VoiceResponse(); + var ttsLanguage = string.IsNullOrWhiteSpace(token) + ? null + : await GetDepartmentTtsLanguageAsync(token); + await _twilioVoiceResponseService.AppendPromptAsync(response, TwilioVoicePromptCatalog.CommunicationTestRecorded, HttpContext?.RequestAborted ?? CancellationToken.None, ttsLanguage); + response.Hangup(); + return new ContentResult { - Content = "Thank you. Your response has been recorded.", + Content = response.ToString(), ContentType = "application/xml", StatusCode = 200 }; } + + private async Task GetDepartmentTtsLanguageAsync(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return null; + + var departmentId = await _communicationTestService.GetDepartmentIdByResponseTokenAsync(token); + + if (!departmentId.HasValue) + return null; + + return await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(departmentId.Value); + } } } diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 23042c56..9d533c46 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -53,6 +53,7 @@ using System.Net.Http; using Resgrid.Providers.Messaging; using Resgrid.Web.Services; +using Resgrid.Web.Services.Twilio; using Twilio.AspNet.Core; namespace Resgrid.Web.ServicesCore @@ -607,6 +608,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddHostedService(); + services.AddScoped(); this.Services = services; //if (Config.ExternalErrorConfig.ApplicationInsightsEnabled) diff --git a/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs b/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs new file mode 100644 index 00000000..ebd5a4dc --- /dev/null +++ b/Web/Resgrid.Web.Services/Twilio/ITwilioVoiceResponseService.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Twilio.TwiML; +using Twilio.TwiML.Voice; + +namespace Resgrid.Web.Services.Twilio +{ + public interface ITwilioVoiceResponseService + { + System.Threading.Tasks.Task AppendPromptAsync(VoiceResponse response, string text, CancellationToken cancellationToken = default, string voice = null); + + System.Threading.Tasks.Task AppendPromptAsync(Gather gather, string text, CancellationToken cancellationToken = default, string voice = null); + + System.Threading.Tasks.Task AppendPromptsAsync(VoiceResponse response, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null); + + System.Threading.Tasks.Task AppendPromptsAsync(Gather gather, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null); + } +} diff --git a/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs new file mode 100644 index 00000000..dec574f7 --- /dev/null +++ b/Web/Resgrid.Web.Services/Twilio/TwilioVoiceResponseService.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Config; +using Resgrid.Model.Services; +using Twilio.TwiML; +using Twilio.TwiML.Voice; + +namespace Resgrid.Web.Services.Twilio +{ + public class TwilioVoiceResponseService : ITwilioVoiceResponseService + { + private readonly ITtsAudioService _ttsAudioService; + private readonly ConcurrentDictionary>> _promptUrlCache = new(StringComparer.Ordinal); + + public TwilioVoiceResponseService(ITtsAudioService ttsAudioService) + { + _ttsAudioService = ttsAudioService; + } + + public async System.Threading.Tasks.Task AppendPromptAsync(VoiceResponse response, string text, CancellationToken cancellationToken = default, string voice = null) + { + foreach (var play in await CreatePlayVerbsAsync(text, voice, cancellationToken)) + { + response.Append(play); + } + } + + public async System.Threading.Tasks.Task AppendPromptAsync(Gather gather, string text, CancellationToken cancellationToken = default, string voice = null) + { + foreach (var play in await CreatePlayVerbsAsync(text, voice, cancellationToken)) + { + gather.Append(play); + } + } + + public async System.Threading.Tasks.Task AppendPromptsAsync(VoiceResponse response, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null) + { + foreach (var prompt in prompts) + { + await AppendPromptAsync(response, prompt, cancellationToken, voice); + } + } + + public async System.Threading.Tasks.Task AppendPromptsAsync(Gather gather, IEnumerable prompts, CancellationToken cancellationToken = default, string voice = null) + { + foreach (var prompt in prompts) + { + await AppendPromptAsync(gather, prompt, cancellationToken, voice); + } + } + + private async Task> CreatePlayVerbsAsync(string text, string voice, CancellationToken cancellationToken) + { + var chunks = ChunkText(text).ToList(); + + if (!chunks.Any()) + { + return new List(); + } + + var urls = await System.Threading.Tasks.Task.WhenAll(chunks.Select(chunk => GetOrCreatePromptUrlAsync(chunk, voice, cancellationToken))); + return urls.Select(CreatePlay).ToList(); + } + + private IEnumerable ChunkText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + yield break; + + var normalized = Regex.Replace(text, @"\s+", " ").Trim(); + var maxLength = TtsConfig.MaxTextLength > 0 ? TtsConfig.MaxTextLength : 1000; + + if (normalized.Length <= maxLength) + { + yield return normalized; + yield break; + } + + var sentences = Regex.Split(normalized, @"(?<=[\.\!\?])\s+") + .Where(sentence => !string.IsNullOrWhiteSpace(sentence)); + var builder = new StringBuilder(); + + foreach (var sentence in sentences) + { + var trimmed = sentence.Trim(); + + if (trimmed.Length > maxLength) + { + foreach (var fragment in ChunkLongSentence(trimmed, maxLength)) + { + if (builder.Length > 0) + { + yield return builder.ToString(); + builder.Clear(); + } + + yield return fragment; + } + + continue; + } + + if (builder.Length == 0) + { + builder.Append(trimmed); + continue; + } + + if (builder.Length + 1 + trimmed.Length <= maxLength) + { + builder.Append(' ').Append(trimmed); + continue; + } + + yield return builder.ToString(); + builder.Clear(); + builder.Append(trimmed); + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + } + } + + private static IEnumerable ChunkLongSentence(string sentence, int maxLength) + { + var words = sentence.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var builder = new StringBuilder(); + + foreach (var word in words) + { + if (word.Length > maxLength) + { + if (builder.Length > 0) + { + yield return builder.ToString(); + builder.Clear(); + } + + for (var index = 0; index < word.Length; index += maxLength) + { + yield return word.Substring(index, Math.Min(maxLength, word.Length - index)); + } + + continue; + } + + if (builder.Length == 0) + { + builder.Append(word); + continue; + } + + if (builder.Length + 1 + word.Length <= maxLength) + { + builder.Append(' ').Append(word); + continue; + } + + yield return builder.ToString(); + builder.Clear(); + builder.Append(word); + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + } + } + + private static Play CreatePlay(Uri url) + { + return new Play + { + Url = url + }; + } + + private async Task GetOrCreatePromptUrlAsync(string chunk, string voice, CancellationToken cancellationToken) + { + var cacheKey = string.IsNullOrWhiteSpace(voice) + ? chunk + : $"{voice.Trim()}\u001F{chunk}"; + var lazyUrl = _promptUrlCache.GetOrAdd( + cacheKey, + _ => new Lazy>( + () => _ttsAudioService.GenerateSpeechUrlAsync(chunk, voice, cancellationToken: CancellationToken.None), + LazyThreadSafetyMode.ExecutionAndPublication)); + var generationTask = lazyUrl.Value; + + try + { + return cancellationToken.CanBeCanceled + ? await generationTask.WaitAsync(cancellationToken) + : await generationTask; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _promptUrlCache.TryRemove(cacheKey, out _); + throw; + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs b/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs new file mode 100644 index 00000000..919c81b8 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Tts.Configuration +{ + public sealed class RateLimitOptions + { + [Range(1, 10000)] + public int PermitLimit { get; set; } = 60; + + [Range(0, 1000)] + public int QueueLimit { get; set; } = 10; + + [Range(1, 3600)] + public int WindowSeconds { get; set; } = 60; + } +} diff --git a/Web/Resgrid.Web.Tts/Configuration/S3StorageOptions.cs b/Web/Resgrid.Web.Tts/Configuration/S3StorageOptions.cs new file mode 100644 index 00000000..51cb7cbd --- /dev/null +++ b/Web/Resgrid.Web.Tts/Configuration/S3StorageOptions.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Tts.Configuration +{ + public sealed class S3StorageOptions + { + public string? Endpoint { get; set; } + + [Required] + public string AccessKey { get; set; } = string.Empty; + + [Required] + public string SecretKey { get; set; } = string.Empty; + + [Required] + public string Bucket { get; set; } = string.Empty; + + [Required] + public string Region { get; set; } = "us-east-1"; + + public bool UseSsl { get; set; } = true; + + public bool ForcePathStyle { get; set; } = true; + + public bool UsePresignedUrls { get; set; } = true; + + [Range(1, 1440)] + public int PresignedUrlExpiryMinutes { get; set; } = 60; + + public string? PublicBaseUrl { get; set; } + } +} diff --git a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..7f148ec7 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Config; + +namespace Resgrid.Web.Tts.Configuration +{ + public static class ServiceCollectionExtensions + { + private const string StaticPromptAdminKeyRequiredMessage = "A static prompt admin key is required."; + + public static IServiceCollection AddTtsConfiguration(this IServiceCollection services) + { + services.AddOptions() + .Configure(ApplyS3Options) + .ValidateDataAnnotations() + .Validate(options => !string.IsNullOrWhiteSpace(options.AccessKey), "S3 access key is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.SecretKey), "S3 secret key is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.Bucket), "S3 bucket is required.") + .ValidateOnStart(); + + services.AddOptions() + .Configure(ApplyTtsOptions) + .ValidateDataAnnotations() + .Validate(options => !string.IsNullOrWhiteSpace(options.DefaultVoice), "A default voice is required.") + .Validate(options => !string.IsNullOrWhiteSpace(options.StaticPromptAdminKey), StaticPromptAdminKeyRequiredMessage) + .Validate(options => options.PreGeneratedPrompts is not null, "Pre-generated prompts must be initialized.") + .ValidateOnStart(); + + services.AddOptions() + .Configure(ApplyRateLimitOptions) + .ValidateDataAnnotations() + .ValidateOnStart(); + + return services; + } + + private static void ApplyS3Options(S3StorageOptions options) + { + options.Endpoint = string.IsNullOrWhiteSpace(TtsConfig.S3Endpoint) ? null : TtsConfig.S3Endpoint; + options.AccessKey = TtsConfig.S3AccessKey; + options.SecretKey = TtsConfig.S3SecretKey; + options.Bucket = TtsConfig.S3Bucket; + options.Region = string.IsNullOrWhiteSpace(TtsConfig.S3Region) ? options.Region : TtsConfig.S3Region; + options.PublicBaseUrl = string.IsNullOrWhiteSpace(TtsConfig.S3PublicBaseUrl) ? null : TtsConfig.S3PublicBaseUrl; + options.ForcePathStyle = TtsConfig.S3ForcePathStyle; + options.UsePresignedUrls = TtsConfig.S3UsePresignedUrls; + options.UseSsl = TtsConfig.S3UseSsl; + options.PresignedUrlExpiryMinutes = TtsConfig.S3PresignedUrlExpiryMinutes; + } + + private static void ApplyTtsOptions(TtsOptions options) + { + options.DefaultVoice = string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice) ? options.DefaultVoice : TtsConfig.DefaultVoice; + options.DefaultSpeed = TtsConfig.DefaultSpeed; + options.MaxConcurrentGenerations = TtsConfig.MaxConcurrentGenerations; + options.MaxTextLength = TtsConfig.MaxTextLength; + options.EspeakExecutable = string.IsNullOrWhiteSpace(TtsConfig.EspeakExecutable) ? options.EspeakExecutable : TtsConfig.EspeakExecutable; + options.FfmpegExecutable = string.IsNullOrWhiteSpace(TtsConfig.FfmpegExecutable) ? options.FfmpegExecutable : TtsConfig.FfmpegExecutable; + options.TempDirectory = string.IsNullOrWhiteSpace(TtsConfig.TempDirectory) ? options.TempDirectory : TtsConfig.TempDirectory; + options.CachePrefix = string.IsNullOrWhiteSpace(TtsConfig.CachePrefix) ? options.CachePrefix : TtsConfig.CachePrefix; + options.NormalizedSampleRate = TtsConfig.NormalizedSampleRate; + options.NormalizedChannels = TtsConfig.NormalizedChannels; + options.PlaybackBaseUrl = !string.IsNullOrWhiteSpace(TtsConfig.PlaybackBaseUrl) + ? TtsConfig.PlaybackBaseUrl + : string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl) + ? options.PlaybackBaseUrl + : TtsConfig.ServiceBaseUrl; + options.PlaybackMemoryCacheMinutes = TtsConfig.PlaybackMemoryCacheMinutes; + options.PlaybackCacheControlSeconds = TtsConfig.PlaybackCacheControlSeconds; + options.WarmupEnabled = TtsConfig.WarmupEnabled; + options.StaticPromptAdminKey = TtsConfig.StaticPromptAdminKey; + options.PreGeneratedPrompts = ParsePrompts(TtsConfig.PreGeneratedPrompts); + } + + private static void ApplyRateLimitOptions(RateLimitOptions options) + { + options.PermitLimit = TtsConfig.RateLimitPermitLimit; + options.QueueLimit = TtsConfig.RateLimitQueueLimit; + options.WindowSeconds = TtsConfig.RateLimitWindowSeconds; + } + + private static List ParsePrompts(string rawPrompts) + { + if (string.IsNullOrWhiteSpace(rawPrompts)) + { + return new List(); + } + + return rawPrompts + .Split([';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs new file mode 100644 index 00000000..85f64ae5 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Tts.Configuration +{ + public sealed class TtsOptions + { + [Required] + public string DefaultVoice { get; set; } = "en-us"; + + [Range(80, 450)] + public int DefaultSpeed { get; set; } = 175; + + [Range(1, 64)] + public int MaxConcurrentGenerations { get; set; } = 4; + + [Range(1, 10000)] + public int MaxTextLength { get; set; } = 1000; + + [Required] + public string EspeakExecutable { get; set; } = "espeak-ng"; + + [Required] + public string FfmpegExecutable { get; set; } = "ffmpeg"; + + [Required] + public string TempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "resgrid-tts"); + + [Required] + public string CachePrefix { get; set; } = "tts"; + + [Range(8000, 8000)] + public int NormalizedSampleRate { get; set; } = 8000; + + [Range(1, 1)] + public int NormalizedChannels { get; set; } = 1; + + [StringLength(2048)] + public string PlaybackBaseUrl { get; set; } = string.Empty; + + [Range(1, 1440)] + public int PlaybackMemoryCacheMinutes { get; set; } = 60; + + [Range(1, 31536000)] + public int PlaybackCacheControlSeconds { get; set; } = 86400; + + public bool WarmupEnabled { get; set; } = true; + + public string StaticPromptAdminKey { get; set; } = string.Empty; + + public List PreGeneratedPrompts { get; set; } = new() + { + "Press 1 for yes", + "Press 2 for no", + "Invalid option", + "Please try again", + "Please stay on the line" + }; + } +} diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs b/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs new file mode 100644 index 00000000..2870294d --- /dev/null +++ b/Web/Resgrid.Web.Tts/Configuration/TtsRequestIdentity.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Resgrid.Config; + +namespace Resgrid.Web.Tts.Configuration +{ + public static class TtsRequestIdentity + { + public static void ConfigureForwardedHeaders(ForwardedHeadersOptions options) + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + AddKnownNetwork(options, WebConfig.IngressProxyNetwork, WebConfig.IngressProxyNetworkCidr); + } + + public static string ResolveRateLimitPartitionKey(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var remoteIpAddress = httpContext.Connection.RemoteIpAddress; + if (remoteIpAddress is null) + { + return "unknown"; + } + + return remoteIpAddress.AddressFamily == AddressFamily.InterNetworkV6 && remoteIpAddress.IsIPv4MappedToIPv6 + ? remoteIpAddress.MapToIPv4().ToString() + : remoteIpAddress.ToString(); + } + + private static void AddKnownNetwork(ForwardedHeadersOptions options, string network, int prefixLength) + { + if (string.IsNullOrWhiteSpace(network) || !IPAddress.TryParse(network, out var parsedNetwork)) + { + return; + } + + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(parsedNetwork, prefixLength)); + + if (parsedNetwork.AddressFamily == AddressFamily.InterNetwork) + { + options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(parsedNetwork.MapToIPv6(), prefixLength)); + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs b/Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs new file mode 100644 index 00000000..91b00829 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Controllers/TtsAdminController.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Models; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Web.Tts.Controllers +{ + [ApiController] + [Route("tts/admin")] + [ApiExplorerSettings(IgnoreApi = true)] + public sealed class TtsAdminController : ControllerBase + { + private const string AdminKeyHeaderName = "X-Resgrid-Admin-Key"; + + private readonly ITtsService _ttsService; + private readonly ITtsPlaybackUrlService _ttsPlaybackUrlService; + private readonly TtsOptions _options; + + public TtsAdminController(ITtsService ttsService, ITtsPlaybackUrlService ttsPlaybackUrlService, IOptions options) + { + _ttsService = ttsService; + _ttsPlaybackUrlService = ttsPlaybackUrlService; + _options = options.Value; + } + + [HttpPost("static-prompts")] + [ProducesResponseType(typeof(StaticPromptRegenerationResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + public async Task> RegenerateStaticPromptsAsync( + [FromHeader(Name = AdminKeyHeaderName)] string adminKey, + [FromBody] StaticPromptRegenerationRequest request, + CancellationToken cancellationToken) + { + if (!IsAuthorized(adminKey)) + { + return Unauthorized(new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Detail = "A valid admin key is required to regenerate static prompts." + }); + } + + var prompts = request?.Prompts?.Where(prompt => !string.IsNullOrWhiteSpace(prompt?.Text)).ToList() + ?? new List(); + + if (prompts.Count == 0) + { + prompts = _options.PreGeneratedPrompts + .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) + .Select(prompt => new TtsRequest + { + Text = prompt.Trim(), + Voice = _options.DefaultVoice, + Speed = _options.DefaultSpeed + }) + .ToList(); + } + + if (prompts.Count == 0) + { + return BadRequest(new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Invalid prompt refresh request", + Detail = "At least one prompt is required." + }); + } + + try + { + var results = await _ttsService.GenerateBatchAsync(prompts, cancellationToken); + + foreach (var result in results) + { + result.Url = _ttsPlaybackUrlService.CreatePlaybackUrl(Request, result.Hash).ToString(); + } + + return Ok(new StaticPromptRegenerationResponse + { + PromptCount = results.Count, + Prompts = results + }); + } + catch (ArgumentException ex) + { + return BadRequest(new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Invalid prompt refresh request", + Detail = ex.Message + }); + } + } + + private bool IsAuthorized(string suppliedKey) + { + if (string.IsNullOrWhiteSpace(_options.StaticPromptAdminKey) || string.IsNullOrWhiteSpace(suppliedKey)) + { + return false; + } + + var configuredBytes = Encoding.UTF8.GetBytes(_options.StaticPromptAdminKey); + var suppliedBytes = Encoding.UTF8.GetBytes(suppliedKey); + + return configuredBytes.Length == suppliedBytes.Length + && CryptographicOperations.FixedTimeEquals(configuredBytes, suppliedBytes); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Controllers/TtsController.cs b/Web/Resgrid.Web.Tts/Controllers/TtsController.cs new file mode 100644 index 00000000..cc047c3b --- /dev/null +++ b/Web/Resgrid.Web.Tts/Controllers/TtsController.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Models; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Web.Tts.Controllers +{ + [ApiController] + [Route("tts")] + [EnableRateLimiting("tts")] + public sealed class TtsController : ControllerBase + { + private readonly ITtsService _ttsService; + private readonly ICacheService _cacheService; + private readonly ITtsPlaybackUrlService _ttsPlaybackUrlService; + private readonly TtsOptions _options; + + public TtsController( + ITtsService ttsService, + ICacheService cacheService, + ITtsPlaybackUrlService ttsPlaybackUrlService, + IOptions options) + { + _ttsService = ttsService; + _cacheService = cacheService; + _ttsPlaybackUrlService = ttsPlaybackUrlService; + _options = options.Value; + } + + [HttpPost] + [ProducesResponseType(typeof(TtsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task> GenerateAsync([FromBody] TtsRequest request, CancellationToken cancellationToken) + { + try + { + var response = await _ttsService.GenerateAsync(request, cancellationToken); + ApplyPlaybackUrl(response); + return Ok(response); + } + catch (ArgumentException ex) + { + return BadRequest(CreateProblemDetails(ex.Message)); + } + } + + [HttpPost("batch")] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task>> GenerateBatchAsync([FromBody] List requests, CancellationToken cancellationToken) + { + try + { + var responses = await _ttsService.GenerateBatchAsync(requests, cancellationToken); + foreach (var response in responses) + { + ApplyPlaybackUrl(response); + } + + return Ok(responses); + } + catch (ArgumentException ex) + { + return BadRequest(CreateProblemDetails(ex.Message)); + } + } + + [HttpGet("audio/{hash:length(64)}.wav")] + [DisableRateLimiting] + [Produces("audio/wav")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetAudioAsync(string hash, CancellationToken cancellationToken) + { + var audio = await _cacheService.TryGetAudioAsync(hash, cancellationToken); + + if (audio is null) + { + return NotFound(); + } + + Response.Headers.CacheControl = $"public,max-age={_options.PlaybackCacheControlSeconds},immutable"; + + return File( + audio.AudioBytes, + audio.ContentType, + lastModified: audio.LastModified, + entityTag: new EntityTagHeaderValue(audio.EntityTag), + enableRangeProcessing: true); + } + + private ProblemDetails CreateProblemDetails(string detail) + { + return new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Invalid TTS request", + Detail = detail + }; + } + + private void ApplyPlaybackUrl(TtsResponse response) + { + response.Url = _ttsPlaybackUrlService.CreatePlaybackUrl(Request, response.Hash).ToString(); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile new file mode 100644 index 00000000..a89f6fbc --- /dev/null +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj", "Web/Resgrid.Web.Tts/"] +RUN dotnet restore "Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj" + +COPY . . +WORKDIR /src/Web/Resgrid.Web.Tts +RUN dotnet publish "Resgrid.Web.Tts.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +RUN apt-get update \ + && apt-get install -y --no-install-recommends espeak-ng ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 appgroup \ + && useradd --uid 10001 --gid appgroup --create-home --shell /usr/sbin/nologin appuser + +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +USER appuser +ENTRYPOINT ["dotnet", "Resgrid.Web.Tts.dll"] diff --git a/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs new file mode 100644 index 00000000..53956160 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Resgrid.Config; +using Resgrid.Web.Tts.Configuration; + +namespace Resgrid.Web.Tts.Health +{ + public sealed class TtsDependencyHealthCheck : IHealthCheck + { + private readonly S3StorageOptions _s3Options; + private readonly TtsOptions _ttsOptions; + + public TtsDependencyHealthCheck( + IOptions s3Options, + IOptions ttsOptions) + { + _s3Options = s3Options.Value; + _ttsOptions = ttsOptions.Value; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var validationErrors = new List(); + + if (string.IsNullOrWhiteSpace(_s3Options.AccessKey)) + { + validationErrors.Add("S3 access key is not configured."); + } + + if (string.IsNullOrWhiteSpace(_s3Options.SecretKey)) + { + validationErrors.Add("S3 secret key is not configured."); + } + + if (string.IsNullOrWhiteSpace(_s3Options.Bucket)) + { + validationErrors.Add("S3 bucket is not configured."); + } + + if (string.IsNullOrWhiteSpace(_ttsOptions.EspeakExecutable)) + { + validationErrors.Add("eSpeak NG executable is not configured."); + } + + if (string.IsNullOrWhiteSpace(_ttsOptions.FfmpegExecutable)) + { + validationErrors.Add("ffmpeg executable is not configured."); + } + + if (string.IsNullOrWhiteSpace(CacheConfig.RedisConnectionString)) + { + validationErrors.Add("Redis connection string is not configured."); + } + + if (validationErrors.Count == 0) + { + return Task.FromResult(HealthCheckResult.Healthy("TTS configuration is ready.")); + } + + return Task.FromResult(HealthCheckResult.Unhealthy(string.Join(" ", validationErrors))); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationRequest.cs b/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationRequest.cs new file mode 100644 index 00000000..4c69b238 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationRequest.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Web.Tts.Models +{ + public sealed class StaticPromptRegenerationRequest + { + public List Prompts { get; set; } = new(); + } +} diff --git a/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationResponse.cs b/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationResponse.cs new file mode 100644 index 00000000..57bb0a4f --- /dev/null +++ b/Web/Resgrid.Web.Tts/Models/StaticPromptRegenerationResponse.cs @@ -0,0 +1,9 @@ +namespace Resgrid.Web.Tts.Models +{ + public sealed class StaticPromptRegenerationResponse + { + public int PromptCount { get; set; } + + public IReadOnlyCollection Prompts { get; set; } = Array.Empty(); + } +} diff --git a/Web/Resgrid.Web.Tts/Models/TtsRequest.cs b/Web/Resgrid.Web.Tts/Models/TtsRequest.cs new file mode 100644 index 00000000..459a6eac --- /dev/null +++ b/Web/Resgrid.Web.Tts/Models/TtsRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Resgrid.Web.Tts.Models +{ + public sealed class TtsRequest + { + [Required] + [StringLength(4000)] + public string Text { get; set; } = string.Empty; + + [StringLength(64)] + public string? Voice { get; set; } + + [Range(80, 450)] + public int? Speed { get; set; } + } +} diff --git a/Web/Resgrid.Web.Tts/Models/TtsResponse.cs b/Web/Resgrid.Web.Tts/Models/TtsResponse.cs new file mode 100644 index 00000000..f751ce35 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Models/TtsResponse.cs @@ -0,0 +1,17 @@ +namespace Resgrid.Web.Tts.Models +{ + public sealed class TtsResponse + { + public string Hash { get; set; } = string.Empty; + + public string ObjectKey { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Voice { get; set; } = string.Empty; + + public int Speed { get; set; } + + public bool Cached { get; set; } + } +} diff --git a/Web/Resgrid.Web.Tts/Program.cs b/Web/Resgrid.Web.Tts/Program.cs new file mode 100644 index 00000000..0e8afbfa --- /dev/null +++ b/Web/Resgrid.Web.Tts/Program.cs @@ -0,0 +1,148 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Resgrid.Config; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Health; +using Resgrid.Web.Tts.Services; +using System.Text.Json; +using System.Threading.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +ConfigProcessor.LoadAndProcessConfig(builder.Configuration["AppOptions:ConfigPath"]); +ConfigProcessor.LoadAndProcessEnvVariables(builder.Configuration.AsEnumerable()); + +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(); + +builder.Services.AddProblemDetails(); +builder.Services.AddControllers(); +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = CacheConfig.RedisConnectionString; + options.InstanceName = $"{SystemBehaviorConfig.GetEnvPrefix()}resgrid-tts:"; +}); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddTtsConfiguration(); +builder.Services.Configure(TtsRequestIdentity.ConfigureForwardedHeaders); +builder.Services.AddHealthChecks() + .AddCheck("tts_dependencies"); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("tts", httpContext => + { + var clientId = TtsRequestIdentity.ResolveRateLimitPartitionKey(httpContext); + var rateLimitOptions = httpContext.RequestServices.GetRequiredService>().Value; + + return RateLimitPartition.GetFixedWindowLimiter( + clientId, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitOptions.PermitLimit, + QueueLimit = rateLimitOptions.QueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + Window = TimeSpan.FromSeconds(rateLimitOptions.WindowSeconds) + }); + }); + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.ContentType = "application/problem+json"; + await context.HttpContext.Response.WriteAsJsonAsync( + new ProblemDetails + { + Status = StatusCodes.Status429TooManyRequests, + Title = "Rate limit exceeded", + Detail = "Too many TTS requests were received. Please retry shortly." + }, + cancellationToken); + }; +}); + +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return CreateS3Client(options); +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseForwardedHeaders(); +app.UseRateLimiter(); + +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = static async (context, report) => + { + context.Response.ContentType = "application/json"; + + var payload = new + { + status = report.Status.ToString(), + checks = report.Entries.ToDictionary( + entry => entry.Key, + entry => new + { + status = entry.Value.Status.ToString(), + description = entry.Value.Description + }) + }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(payload), context.RequestAborted); + } +}); +app.MapControllers(); + +app.Run(); + +static AmazonS3Client CreateS3Client(S3StorageOptions options) +{ + var credentials = new BasicAWSCredentials(options.AccessKey, options.SecretKey); + var config = new AmazonS3Config + { + ForcePathStyle = options.ForcePathStyle, + AuthenticationRegion = options.Region + }; + + if (!string.IsNullOrWhiteSpace(options.Endpoint)) + { + if (Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri)) + { + config.ServiceURL = endpointUri.GetLeftPart(UriPartial.Authority); + config.UseHttp = endpointUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + } + else + { + config.ServiceURL = $"{(options.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp)}://{options.Endpoint}"; + config.UseHttp = !options.UseSsl; + } + } + else + { + config.RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region); + } + + return new AmazonS3Client(credentials, config); +} + +public partial class Program; diff --git a/Web/Resgrid.Web.Tts/Properties/launchSettings.json b/Web/Resgrid.Web.Tts/Properties/launchSettings.json new file mode 100644 index 00000000..b4c6ac56 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17601", + "sslPort": 0 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5299", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj new file mode 100644 index 00000000..4d2441b0 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj @@ -0,0 +1,21 @@ + + + net8.0 + enable + enable + Linux-ready TTS microservice backed by eSpeak NG, ffmpeg, and S3-compatible storage. + Resgrid.Web.Tts + Resgrid.Web.Tts + Debug;Release;Docker + + + + + + + + + + + + diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs new file mode 100644 index 00000000..d8c8cddd --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using System.Diagnostics; +using System.Globalization; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class AudioProcessingService : IAudioProcessingService + { + private readonly TtsOptions _options; + private readonly ILogger _logger; + + public AudioProcessingService( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task GenerateNormalizedWavAsync(string text, string voice, int speed, CancellationToken cancellationToken) + { + var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.TempDirectory) ? Path.GetTempPath() : _options.TempDirectory); + var workingDirectory = Path.Combine(tempRoot, Guid.NewGuid().ToString("N")); + var rawFilePath = Path.Combine(workingDirectory, "raw.wav"); + var normalizedFilePath = Path.Combine(workingDirectory, "normalized.wav"); + + Directory.CreateDirectory(workingDirectory); + + try + { + await RunEspeakAsync(text, voice, speed, rawFilePath, cancellationToken); + await RunFfmpegAsync(rawFilePath, normalizedFilePath, cancellationToken); + + return await File.ReadAllBytesAsync(normalizedFilePath, cancellationToken); + } + finally + { + TryDeleteDirectory(workingDirectory); + } + } + + private async Task RunEspeakAsync(string text, string voice, int speed, string outputFilePath, CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.EspeakExecutable, redirectStandardInput: true); + startInfo.ArgumentList.Add("--stdin"); + startInfo.ArgumentList.Add("-w"); + startInfo.ArgumentList.Add(outputFilePath); + startInfo.ArgumentList.Add("-v"); + startInfo.ArgumentList.Add(voice); + startInfo.ArgumentList.Add("-s"); + startInfo.ArgumentList.Add(speed.ToString(CultureInfo.InvariantCulture)); + + await RunProcessAsync(startInfo, text, "eSpeak NG", cancellationToken); + } + + private async Task RunFfmpegAsync(string inputFilePath, string outputFilePath, CancellationToken cancellationToken) + { + var startInfo = CreateStartInfo(_options.FfmpegExecutable); + startInfo.ArgumentList.Add("-nostdin"); + startInfo.ArgumentList.Add("-loglevel"); + startInfo.ArgumentList.Add("error"); + startInfo.ArgumentList.Add("-y"); + startInfo.ArgumentList.Add("-i"); + startInfo.ArgumentList.Add(inputFilePath); + startInfo.ArgumentList.Add("-ar"); + startInfo.ArgumentList.Add(_options.NormalizedSampleRate.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add("-ac"); + startInfo.ArgumentList.Add(_options.NormalizedChannels.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add("-acodec"); + startInfo.ArgumentList.Add("pcm_s16le"); + startInfo.ArgumentList.Add(outputFilePath); + + await RunProcessAsync(startInfo, null, "ffmpeg", cancellationToken); + } + + private static ProcessStartInfo CreateStartInfo(string fileName, bool redirectStandardInput = false) + { + return new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = redirectStandardInput + }; + } + + private async Task RunProcessAsync(ProcessStartInfo startInfo, string? standardInput, string processName, CancellationToken cancellationToken) + { + using var process = new Process + { + StartInfo = startInfo + }; + + if (!process.Start()) + { + throw new InvalidOperationException($"{processName} failed to start."); + } + + using var cancellationRegistration = cancellationToken.Register(() => TryKillProcess(process)); + + var standardErrorTask = process.StandardError.ReadToEndAsync(); + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + + if (standardInput is not null) + { + await process.StandardInput.WriteAsync(standardInput.AsMemory(), cancellationToken); + await process.StandardInput.FlushAsync(); + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(cancellationToken); + + var standardError = await standardErrorTask; + var standardOutput = await standardOutputTask; + + if (process.ExitCode != 0) + { + var output = string.IsNullOrWhiteSpace(standardError) ? standardOutput : standardError; + throw new InvalidOperationException($"{processName} exited with code {process.ExitCode}: {output}"); + } + } + + private void TryDeleteDirectory(string workingDirectory) + { + if (!Directory.Exists(workingDirectory)) + { + return; + } + + try + { + Directory.Delete(workingDirectory, recursive: true); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete temporary TTS directory {WorkingDirectory}", workingDirectory); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Failed to delete temporary TTS directory {WorkingDirectory}", workingDirectory); + } + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + catch (NotSupportedException) + { + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/CacheService.cs b/Web/Resgrid.Web.Tts/Services/CacheService.cs new file mode 100644 index 00000000..ceae8de3 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/CacheService.cs @@ -0,0 +1,208 @@ +using System.IO; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class CacheService : ICacheService + { + private static readonly Regex HashPattern = new("^[a-f0-9]{64}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private const byte CachePayloadVersion = 1; + + private readonly IStorageService _storageService; + private readonly IDistributedCache _distributedCache; + private readonly TtsOptions _options; + + public CacheService(IStorageService storageService, IDistributedCache distributedCache, IOptions options) + { + _storageService = storageService; + _distributedCache = distributedCache; + _options = options.Value; + } + + public TtsCacheKey CreateCacheKey(string text, string voice, int speed) + { + using var sha256 = SHA256.Create(); + + var payload = $"{voice}\u001F{speed}\u001F{text}"; + var hash = Convert.ToHexString(sha256.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); + return CreateCacheKeyFromHash(hash); + } + + public async Task TryGetCachedUrlAsync(TtsCacheKey cacheKey, CancellationToken cancellationToken) + { + if (await TryGetCachedAudioAsync(cacheKey.Hash, cancellationToken) is not null) + { + return await _storageService.GetObjectUrlAsync(cacheKey.ObjectKey, cancellationToken); + } + + if (!await _storageService.ExistsAsync(cacheKey.ObjectKey, cancellationToken)) + { + return null; + } + + return await _storageService.GetObjectUrlAsync(cacheKey.ObjectKey, cancellationToken); + } + + public async Task TryGetAudioAsync(string hash, CancellationToken cancellationToken) + { + if (!TryCreateCacheKeyFromHash(hash, out var cacheKey)) + { + return null; + } + + var cachedAudio = await TryGetCachedAudioAsync(cacheKey.Hash, cancellationToken); + + if (cachedAudio is not null) + { + return cachedAudio; + } + + var audio = await _storageService.GetObjectAsync(cacheKey.ObjectKey, cancellationToken); + + if (audio is null) + { + return null; + } + + await SetCachedAudioAsync(cacheKey.Hash, audio, cancellationToken); + return audio; + } + + public async Task StoreAsync(TtsCacheKey cacheKey, byte[] audioBytes, CancellationToken cancellationToken) + { + using var stream = new MemoryStream(audioBytes, writable: false); + + await _storageService.UploadAsync(cacheKey.ObjectKey, stream, "audio/wav", cancellationToken); + await SetCachedAudioAsync(cacheKey.Hash, CreateAudioContent(audioBytes), cancellationToken); + + return await _storageService.GetObjectUrlAsync(cacheKey.ObjectKey, cancellationToken); + } + + private TtsCacheKey CreateCacheKeyFromHash(string hash) + { + var normalizedHash = hash.Trim().ToLowerInvariant(); + var prefix = string.IsNullOrWhiteSpace(_options.CachePrefix) + ? string.Empty + : _options.CachePrefix.Trim().Trim('/'); + var objectKey = string.IsNullOrWhiteSpace(prefix) + ? $"{normalizedHash}.wav" + : $"{prefix}/{normalizedHash}.wav"; + + return new TtsCacheKey(normalizedHash, objectKey); + } + + private bool TryCreateCacheKeyFromHash(string hash, out TtsCacheKey cacheKey) + { + if (string.IsNullOrWhiteSpace(hash) || !HashPattern.IsMatch(hash)) + { + cacheKey = default; + return false; + } + + cacheKey = CreateCacheKeyFromHash(hash); + return true; + } + + private TtsAudioContent CreateAudioContent(byte[] audioBytes) + { + return new TtsAudioContent( + audioBytes, + "audio/wav", + CreateEntityTag(audioBytes), + DateTimeOffset.UtcNow); + } + + private async Task TryGetCachedAudioAsync(string hash, CancellationToken cancellationToken) + { + var payload = await _distributedCache.GetAsync(GetAudioCacheEntryKey(hash), cancellationToken); + + if (payload is null || payload.Length == 0) + { + return null; + } + + try + { + return DeserializeAudioContent(payload); + } + catch (InvalidDataException) + { + await _distributedCache.RemoveAsync(GetAudioCacheEntryKey(hash), cancellationToken); + return null; + } + } + + private Task SetCachedAudioAsync(string hash, TtsAudioContent audio, CancellationToken cancellationToken) + { + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.PlaybackMemoryCacheMinutes) + }; + + return _distributedCache.SetAsync( + GetAudioCacheEntryKey(hash), + SerializeAudioContent(audio), + options, + cancellationToken); + } + + private static string GetAudioCacheEntryKey(string hash) => $"tts-audio::{hash}"; + + private static string CreateEntityTag(byte[] audioBytes) + { + using var sha256 = SHA256.Create(); + return $"\"{Convert.ToHexString(sha256.ComputeHash(audioBytes)).ToLowerInvariant()}\""; + } + + private static byte[] SerializeAudioContent(TtsAudioContent audio) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + + writer.Write(CachePayloadVersion); + writer.Write(audio.ContentType); + writer.Write(audio.EntityTag); + writer.Write(audio.LastModified.UtcDateTime.Ticks); + writer.Write(audio.AudioBytes.Length); + writer.Write(audio.AudioBytes); + writer.Flush(); + + return stream.ToArray(); + } + + private static TtsAudioContent DeserializeAudioContent(byte[] payload) + { + using var stream = new MemoryStream(payload, writable: false); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + + if (reader.ReadByte() != CachePayloadVersion) + { + throw new InvalidDataException("Unsupported TTS audio cache payload version."); + } + + var contentType = reader.ReadString(); + var entityTag = reader.ReadString(); + var lastModified = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); + var audioLength = reader.ReadInt32(); + + if (audioLength < 0 || audioLength > stream.Length - stream.Position) + { + throw new InvalidDataException("The TTS audio cache payload length is invalid."); + } + + var audioBytes = reader.ReadBytes(audioLength); + + if (audioBytes.Length != audioLength) + { + throw new InvalidDataException("The TTS audio cache payload is truncated."); + } + + return new TtsAudioContent(audioBytes, contentType, entityTag, lastModified); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs new file mode 100644 index 00000000..2ae39524 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs @@ -0,0 +1,7 @@ +namespace Resgrid.Web.Tts.Services +{ + public interface IAudioProcessingService + { + Task GenerateNormalizedWavAsync(string text, string voice, int speed, CancellationToken cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/ICacheService.cs b/Web/Resgrid.Web.Tts/Services/ICacheService.cs new file mode 100644 index 00000000..561a8bc4 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/ICacheService.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Web.Tts.Services +{ + public interface ICacheService + { + TtsCacheKey CreateCacheKey(string text, string voice, int speed); + + Task TryGetCachedUrlAsync(TtsCacheKey cacheKey, CancellationToken cancellationToken); + + Task TryGetAudioAsync(string hash, CancellationToken cancellationToken); + + Task StoreAsync(TtsCacheKey cacheKey, byte[] audioBytes, CancellationToken cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/IStorageService.cs b/Web/Resgrid.Web.Tts/Services/IStorageService.cs new file mode 100644 index 00000000..938263b0 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/IStorageService.cs @@ -0,0 +1,13 @@ +namespace Resgrid.Web.Tts.Services +{ + public interface IStorageService + { + Task ExistsAsync(string objectKey, CancellationToken cancellationToken); + + Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken); + + Task GetObjectAsync(string objectKey, CancellationToken cancellationToken); + + Task GetObjectUrlAsync(string objectKey, CancellationToken cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/ITtsPlaybackUrlService.cs b/Web/Resgrid.Web.Tts/Services/ITtsPlaybackUrlService.cs new file mode 100644 index 00000000..6b1aaaa2 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/ITtsPlaybackUrlService.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace Resgrid.Web.Tts.Services +{ + public interface ITtsPlaybackUrlService + { + Uri CreatePlaybackUrl(HttpRequest? request, string hash); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/ITtsService.cs b/Web/Resgrid.Web.Tts/Services/ITtsService.cs new file mode 100644 index 00000000..6a4fe8e2 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/ITtsService.cs @@ -0,0 +1,13 @@ +using Resgrid.Web.Tts.Models; + +namespace Resgrid.Web.Tts.Services +{ + public interface ITtsService + { + Task GenerateAsync(TtsRequest request, CancellationToken cancellationToken); + + Task> GenerateBatchAsync(IEnumerable requests, CancellationToken cancellationToken); + + Task WarmPromptsAsync(CancellationToken cancellationToken); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs b/Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs new file mode 100644 index 00000000..c6bfb400 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/PromptWarmupHostedService.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class PromptWarmupHostedService : BackgroundService + { + private readonly ITtsService _ttsService; + private readonly TtsOptions _options; + private readonly ILogger _logger; + + public PromptWarmupHostedService( + ITtsService ttsService, + IOptions options, + ILogger logger) + { + _ttsService = ttsService; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.WarmupEnabled) + { + _logger.LogInformation("TTS prompt warmup is disabled."); + return; + } + + if (_options.PreGeneratedPrompts.Count == 0) + { + _logger.LogInformation("No pre-generated TTS prompts were configured."); + return; + } + + _logger.LogInformation("Warming {PromptCount} pre-generated TTS prompts.", _options.PreGeneratedPrompts.Count); + + try + { + await _ttsService.WarmPromptsAsync(stoppingToken); + _logger.LogInformation("Finished warming pre-generated TTS prompts."); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("TTS prompt warmup was cancelled."); + } + catch (Exception ex) + { + _logger.LogError(ex, "TTS prompt warmup failed but will not stop host."); + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs new file mode 100644 index 00000000..05f8123c --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -0,0 +1,261 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using System.Net; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class S3StorageService : IStorageService + { + private const int MaxRetryAttempts = 3; + + private readonly IAmazonS3 _s3Client; + private readonly S3StorageOptions _options; + private readonly ILogger _logger; + + public S3StorageService( + IAmazonS3 s3Client, + IOptions options, + ILogger logger) + { + _s3Client = s3Client; + _options = options.Value; + _logger = logger; + } + + public async Task ExistsAsync(string objectKey, CancellationToken cancellationToken) + { + try + { + await ExecuteWithRetryAsync( + () => _s3Client.GetObjectMetadataAsync( + new GetObjectMetadataRequest + { + BucketName = _options.Bucket, + Key = objectKey + }, + cancellationToken), + $"checking metadata for {objectKey}", + cancellationToken); + + return true; + } + catch (AmazonS3Exception ex) when (IsNotFound(ex)) + { + return false; + } + } + + public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) + { + MemoryStream? bufferedContent = null; + var uploadContent = content; + + if (!content.CanSeek) + { + bufferedContent = new MemoryStream(); + await content.CopyToAsync(bufferedContent, cancellationToken); + bufferedContent.Position = 0; + uploadContent = bufferedContent; + } + + try + { + await ExecuteWithRetryAsync( + () => + { + if (uploadContent.CanSeek) + { + uploadContent.Position = 0; + } + + return _s3Client.PutObjectAsync( + new PutObjectRequest + { + BucketName = _options.Bucket, + Key = objectKey, + InputStream = uploadContent, + ContentType = contentType + }, + cancellationToken); + }, + $"uploading {objectKey}", + cancellationToken); + } + finally + { + if (bufferedContent is not null) + { + await bufferedContent.DisposeAsync(); + } + } + } + + public async Task GetObjectAsync(string objectKey, CancellationToken cancellationToken) + { + try + { + using var response = await ExecuteWithRetryAsync( + () => _s3Client.GetObjectAsync( + new GetObjectRequest + { + BucketName = _options.Bucket, + Key = objectKey + }, + cancellationToken), + $"downloading {objectKey}", + cancellationToken); + + await using var responseStream = response.ResponseStream; + using var memoryStream = new MemoryStream(); + await responseStream.CopyToAsync(memoryStream, cancellationToken); + + var audioBytes = memoryStream.ToArray(); + var contentType = string.IsNullOrWhiteSpace(response.Headers.ContentType) + ? "audio/wav" + : response.Headers.ContentType; + var entityTag = string.IsNullOrWhiteSpace(response.ETag) + ? CreateEntityTag(audioBytes) + : NormalizeEntityTag(response.ETag); + var lastModified = response.LastModified == default + ? DateTimeOffset.UtcNow + : new DateTimeOffset(DateTime.SpecifyKind(response.LastModified, DateTimeKind.Utc)); + + return new TtsAudioContent(audioBytes, contentType, entityTag, lastModified); + } + catch (AmazonS3Exception ex) when (IsNotFound(ex)) + { + return null; + } + } + + public Task GetObjectUrlAsync(string objectKey, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl)) + { + return Task.FromResult(new Uri($"{_options.PublicBaseUrl.TrimEnd('/')}/{objectKey}")); + } + + if (_options.UsePresignedUrls) + { + var url = _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest + { + BucketName = _options.Bucket, + Key = objectKey, + Expires = DateTime.UtcNow.AddMinutes(_options.PresignedUrlExpiryMinutes) + }); + + return Task.FromResult(new Uri(url)); + } + + return Task.FromResult(BuildDirectObjectUrl(objectKey)); + } + + private async Task ExecuteWithRetryAsync(Func> operation, string operationName, CancellationToken cancellationToken) + { + for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) + { + try + { + return await operation(); + } + catch (AmazonS3Exception ex) when (IsNotFound(ex)) + { + throw; + } + catch (AmazonServiceException ex) when (attempt < MaxRetryAttempts && IsTransient(ex)) + { + await DelayRetryAsync(operationName, attempt, ex, cancellationToken); + } + catch (HttpRequestException ex) when (attempt < MaxRetryAttempts) + { + await DelayRetryAsync(operationName, attempt, ex, cancellationToken); + } + catch (IOException ex) when (attempt < MaxRetryAttempts) + { + await DelayRetryAsync(operationName, attempt, ex, cancellationToken); + } + } + + throw new InvalidOperationException($"S3 operation retry loop terminated unexpectedly for {operationName}."); + } + + private async Task DelayRetryAsync(string operationName, int attempt, Exception exception, CancellationToken cancellationToken) + { + var delay = TimeSpan.FromMilliseconds(150 * Math.Pow(2, attempt - 1)); + + _logger.LogWarning( + exception, + "Transient S3 failure during {OperationName} on attempt {Attempt}. Retrying in {DelayMs} ms.", + operationName, + attempt, + delay.TotalMilliseconds); + + await Task.Delay(delay, cancellationToken); + } + + private bool IsTransient(AmazonServiceException exception) + { + return exception.StatusCode == HttpStatusCode.RequestTimeout + || (int)exception.StatusCode >= 500 + || exception.InnerException is HttpRequestException + || exception.InnerException is IOException; + } + + private static bool IsNotFound(AmazonS3Exception exception) + { + return exception.StatusCode == HttpStatusCode.NotFound + || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase) + || string.Equals(exception.ErrorCode, "NotFound", StringComparison.OrdinalIgnoreCase); + } + + private Uri BuildDirectObjectUrl(string objectKey) + { + if (!string.IsNullOrWhiteSpace(_options.Endpoint)) + { + var endpointUri = GetEndpointUri(); + var authority = endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; + + if (_options.ForcePathStyle) + { + return new Uri($"{endpointUri.Scheme}://{authority}/{_options.Bucket}/{objectKey}"); + } + + return new Uri($"{endpointUri.Scheme}://{_options.Bucket}.{authority}/{objectKey}"); + } + + return new Uri($"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{objectKey}"); + } + + private Uri GetEndpointUri() + { + if (Uri.TryCreate(_options.Endpoint, UriKind.Absolute, out var uri)) + { + return uri; + } + + var scheme = _options.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + return new Uri($"{scheme}://{_options.Endpoint}"); + } + + private static string NormalizeEntityTag(string entityTag) + { + var trimmed = entityTag.Trim(); + + return trimmed.StartsWith("\"", StringComparison.Ordinal) + ? trimmed + : $"\"{trimmed.Trim('\"')}\""; + } + + private static string CreateEntityTag(byte[] audioBytes) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + return $"\"{Convert.ToHexString(sha256.ComputeHash(audioBytes)).ToLowerInvariant()}\""; + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs b/Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs new file mode 100644 index 00000000..cfeb6ecf --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TtsAudioContent.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Web.Tts.Services +{ + public sealed record TtsAudioContent(byte[] AudioBytes, string ContentType, string EntityTag, DateTimeOffset LastModified); +} diff --git a/Web/Resgrid.Web.Tts/Services/TtsCacheKey.cs b/Web/Resgrid.Web.Tts/Services/TtsCacheKey.cs new file mode 100644 index 00000000..bb93398f --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TtsCacheKey.cs @@ -0,0 +1,4 @@ +namespace Resgrid.Web.Tts.Services +{ + public sealed record TtsCacheKey(string Hash, string ObjectKey); +} diff --git a/Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs b/Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs new file mode 100644 index 00000000..493a6b44 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TtsPlaybackUrlService.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class TtsPlaybackUrlService : ITtsPlaybackUrlService + { + private readonly TtsOptions _options; + + public TtsPlaybackUrlService(IOptions options) + { + _options = options.Value; + } + + public Uri CreatePlaybackUrl(HttpRequest? request, string hash) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hash); + var normalizedHash = hash.Trim().ToLowerInvariant(); + + if (normalizedHash.IndexOfAny(['/', '?', '#']) >= 0) + { + throw new ArgumentException("Playback hash cannot contain path or URL delimiter characters.", nameof(hash)); + } + + var safeHash = Uri.EscapeDataString(normalizedHash); + + var baseUrl = !string.IsNullOrWhiteSpace(_options.PlaybackBaseUrl) + ? _options.PlaybackBaseUrl + : request is not null && request.Host.HasValue + ? $"{request.Scheme}://{request.Host}{request.PathBase}" + : null; + + if (string.IsNullOrWhiteSpace(baseUrl)) + { + throw new InvalidOperationException("A public playback base URL is required to generate TTS playback URLs."); + } + + return new Uri($"{baseUrl.TrimEnd('/')}/tts/audio/{safeHash}.wav", UriKind.Absolute); + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs new file mode 100644 index 00000000..b3f353c9 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -0,0 +1,275 @@ +using Amazon.Runtime; +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Models; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Resgrid.Web.Tts.Services +{ + public sealed class TtsService : ITtsService + { + private readonly ICacheService _cacheService; + private readonly IAudioProcessingService _audioProcessingService; + private readonly TtsOptions _options; + private readonly ILogger _logger; + private readonly SemaphoreSlim _generationSemaphore; + private readonly ConcurrentDictionary _generationLocks = new(StringComparer.Ordinal); + + public TtsService( + ICacheService cacheService, + IAudioProcessingService audioProcessingService, + IOptions options, + ILogger logger) + { + _cacheService = cacheService; + _audioProcessingService = audioProcessingService; + _options = options.Value; + _logger = logger; + _generationSemaphore = new SemaphoreSlim(_options.MaxConcurrentGenerations, _options.MaxConcurrentGenerations); + } + + public async Task GenerateAsync(TtsRequest request, CancellationToken cancellationToken) + { + var normalizedRequest = NormalizeRequest(request); + return await GenerateInternalAsync(normalizedRequest, cancellationToken); + } + + public async Task> GenerateBatchAsync(IEnumerable requests, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(requests); + + var normalizedRequests = requests.Select(NormalizeRequest).ToList(); + + if (normalizedRequests.Count == 0) + { + throw new ArgumentException("At least one TTS request is required.", nameof(requests)); + } + + var responses = await Task.WhenAll(normalizedRequests.Select(request => GenerateInternalAsync(request, cancellationToken))); + return responses; + } + + public async Task WarmPromptsAsync(CancellationToken cancellationToken) + { + var prompts = _options.PreGeneratedPrompts + .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) + .Select(prompt => prompt.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + foreach (var prompt in prompts) + { + try + { + await GenerateInternalAsync(new NormalizedTtsRequest(prompt, _options.DefaultVoice, _options.DefaultSpeed), cancellationToken); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, "Configured pre-generated prompt is invalid: {Prompt}", prompt); + } + catch (AmazonServiceException ex) + { + _logger.LogError(ex, "Failed to warm prompt {Prompt} because S3 returned an error.", prompt); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to warm prompt {Prompt} because storage connectivity failed.", prompt); + } + catch (IOException ex) + { + _logger.LogError(ex, "Failed to warm prompt {Prompt} because audio files could not be processed.", prompt); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to warm prompt {Prompt} because audio generation failed.", prompt); + } + } + } + + private async Task GenerateInternalAsync(NormalizedTtsRequest request, CancellationToken cancellationToken) + { + var cacheKey = _cacheService.CreateCacheKey(request.Text, request.Voice, request.Speed); + var cachedUrl = await _cacheService.TryGetCachedUrlAsync(cacheKey, cancellationToken); + + if (cachedUrl is not null) + { + _logger.LogInformation("TTS cache hit for {Hash}", cacheKey.Hash); + return CreateResponse(cacheKey, request, cachedUrl, cached: true); + } + + _logger.LogInformation("TTS cache miss for {Hash}", cacheKey.Hash); + + using var generationLock = await AcquireGenerationLockAsync(cacheKey.Hash, cancellationToken); + await _generationSemaphore.WaitAsync(cancellationToken); + + try + { + cachedUrl = await _cacheService.TryGetCachedUrlAsync(cacheKey, cancellationToken); + + if (cachedUrl is not null) + { + _logger.LogInformation("TTS cache filled while waiting for {Hash}", cacheKey.Hash); + return CreateResponse(cacheKey, request, cachedUrl, cached: true); + } + + var generationTimer = Stopwatch.StartNew(); + var audioBytes = await _audioProcessingService.GenerateNormalizedWavAsync(request.Text, request.Voice, request.Speed, cancellationToken); + var objectUrl = await _cacheService.StoreAsync(cacheKey, audioBytes, cancellationToken); + generationTimer.Stop(); + + _logger.LogInformation("Generated audio for {Hash} in {ElapsedMilliseconds} ms", cacheKey.Hash, generationTimer.ElapsedMilliseconds); + + return CreateResponse(cacheKey, request, objectUrl, cached: false); + } + finally + { + _generationSemaphore.Release(); + } + } + + private async Task AcquireGenerationLockAsync(string hash, CancellationToken cancellationToken) + { + while (true) + { + if (_generationLocks.TryGetValue(hash, out var existingLock)) + { + Interlocked.Increment(ref existingLock.ReferenceCount); + + if (_generationLocks.TryGetValue(hash, out var currentLock) && ReferenceEquals(existingLock, currentLock)) + { + try + { + await existingLock.Semaphore.WaitAsync(cancellationToken); + return new GenerationLockLease(this, hash, existingLock); + } + catch + { + ReleaseGenerationLockReference(hash, existingLock); + throw; + } + } + + ReleaseGenerationLockReference(hash, existingLock); + continue; + } + + var newLock = new GenerationLock(); + + if (_generationLocks.TryAdd(hash, newLock)) + { + try + { + await newLock.Semaphore.WaitAsync(cancellationToken); + return new GenerationLockLease(this, hash, newLock); + } + catch + { + ReleaseGenerationLockReference(hash, newLock); + throw; + } + } + } + } + + private void ReleaseGenerationLock(string hash, GenerationLock generationLock) + { + generationLock.Semaphore.Release(); + ReleaseGenerationLockReference(hash, generationLock); + } + + private void ReleaseGenerationLockReference(string hash, GenerationLock generationLock) + { + if (Interlocked.Decrement(ref generationLock.ReferenceCount) == 0 + && _generationLocks.TryRemove(new KeyValuePair(hash, generationLock))) + { + generationLock.Semaphore.Dispose(); + } + } + + private NormalizedTtsRequest NormalizeRequest(TtsRequest? request) + { + if (request is null) + { + throw new ArgumentException("A TTS request payload is required.", nameof(request)); + } + + var text = request.Text?.Trim(); + + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentException("Text is required.", nameof(request)); + } + + if (text.Length > _options.MaxTextLength) + { + throw new ArgumentException($"Text exceeds the configured maximum length of {_options.MaxTextLength} characters.", nameof(request)); + } + + var voice = string.IsNullOrWhiteSpace(request.Voice) + ? _options.DefaultVoice + : request.Voice.Trim(); + var speed = request.Speed ?? _options.DefaultSpeed; + + if (speed < 80 || speed > 450) + { + throw new ArgumentException("Speed must be between 80 and 450.", nameof(request)); + } + + return new NormalizedTtsRequest(text, voice, speed); + } + + private static TtsResponse CreateResponse(TtsCacheKey cacheKey, NormalizedTtsRequest request, Uri objectUrl, bool cached) + { + return new TtsResponse + { + Hash = cacheKey.Hash, + ObjectKey = cacheKey.ObjectKey, + Url = objectUrl.ToString(), + Voice = request.Voice, + Speed = request.Speed, + Cached = cached + }; + } + + private sealed class GenerationLock + { + public GenerationLock() + { + ReferenceCount = 1; + } + + public SemaphoreSlim Semaphore { get; } = new(1, 1); + + public int ReferenceCount; + } + + private sealed class GenerationLockLease : IDisposable + { + private readonly TtsService _service; + private readonly string _hash; + private readonly GenerationLock _generationLock; + private bool _disposed; + + public GenerationLockLease(TtsService service, string hash, GenerationLock generationLock) + { + _service = service; + _hash = hash; + _generationLock = generationLock; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _service.ReleaseGenerationLock(_hash, _generationLock); + _disposed = true; + } + } + + private sealed record NormalizedTtsRequest(string Text, string Voice, int Speed); + } +} diff --git a/Web/Resgrid.Web.Tts/appsettings.Development.json b/Web/Resgrid.Web.Tts/appsettings.Development.json new file mode 100644 index 00000000..87a69e3c --- /dev/null +++ b/Web/Resgrid.Web.Tts/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + }, + "AppOptions": { + "ConfigPath": "C:\\Resgrid\\Config\\ResgridConfig.json" + } +} diff --git a/Web/Resgrid.Web.Tts/appsettings.json b/Web/Resgrid.Web.Tts/appsettings.json new file mode 100644 index 00000000..b3b0679f --- /dev/null +++ b/Web/Resgrid.Web.Tts/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Amazon": "Warning" + } + }, + "AppOptions": { + "ConfigPath": "" + }, + "_Comment": "TTS runtime settings are loaded from Resgrid.Config.TtsConfig via ResgridConfig.json or RESGRID:TtsConfig:* environment variables.", + "AllowedHosts": "*", + "ConnectionStrings": { + } +} diff --git a/Web/Resgrid.Web.Tts/k8s/deployment.yaml b/Web/Resgrid.Web.Tts/k8s/deployment.yaml new file mode 100644 index 00000000..a4f44374 --- /dev/null +++ b/Web/Resgrid.Web.Tts/k8s/deployment.yaml @@ -0,0 +1,187 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: resgrid-tts-config +data: + tts-s3-endpoint: https://minio.example.com + tts-s3-region: us-east-1 + tts-s3-bucket: tts-audio + tts-s3-public-base-url: "" + tts-default-voice: en-us + tts-default-speed: "175" + tts-playback-base-url: "" + tts-playback-memory-cache-minutes: "60" + tts-playback-cache-control-seconds: "86400" + tts-max-concurrent-generations: "4" + tts-temp-directory: /tmp/resgrid-tts + tts-pregenerated-prompts: Press 1 for yes;Press 2 for no;Invalid option;Please try again + tts-static-prompt-refresh-interval-minutes: "1440" +--- +apiVersion: v1 +kind: Secret +metadata: + name: resgrid-tts-secrets +type: Opaque +stringData: + tts-s3-access-key: change-me + tts-s3-secret-key: change-me + tts-static-prompt-admin-key: change-me + tts-redis-connection-string: change-me +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: resgrid-tts +spec: + replicas: 2 + revisionHistoryLimit: 3 + selector: + matchLabels: + app: resgrid-tts + template: + metadata: + labels: + app: resgrid-tts + spec: + securityContext: + fsGroup: 10001 + containers: + - name: resgrid-tts + image: resgridllc/resgridwebtts:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + env: + - name: ASPNETCORE_URLS + value: http://+:8080 + - name: RESGRID__TtsConfig__S3Endpoint + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-s3-endpoint + - name: RESGRID__TtsConfig__S3Region + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-s3-region + - name: RESGRID__TtsConfig__S3Bucket + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-s3-bucket + - name: RESGRID__TtsConfig__S3PublicBaseUrl + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-s3-public-base-url + - name: RESGRID__TtsConfig__S3AccessKey + valueFrom: + secretKeyRef: + name: resgrid-tts-secrets + key: tts-s3-access-key + - name: RESGRID__TtsConfig__S3SecretKey + valueFrom: + secretKeyRef: + name: resgrid-tts-secrets + key: tts-s3-secret-key + - name: RESGRID__TtsConfig__DefaultVoice + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-default-voice + - name: RESGRID__TtsConfig__DefaultSpeed + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-default-speed + - name: RESGRID__TtsConfig__PlaybackBaseUrl + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-playback-base-url + - name: RESGRID__TtsConfig__PlaybackMemoryCacheMinutes + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-playback-memory-cache-minutes + - name: RESGRID__TtsConfig__PlaybackCacheControlSeconds + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-playback-cache-control-seconds + - name: RESGRID__TtsConfig__MaxConcurrentGenerations + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-max-concurrent-generations + - name: RESGRID__TtsConfig__TempDirectory + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-temp-directory + - name: RESGRID__TtsConfig__PreGeneratedPrompts + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-pregenerated-prompts + - name: RESGRID__TtsConfig__StaticPromptRefreshIntervalMinutes + valueFrom: + configMapKeyRef: + name: resgrid-tts-config + key: tts-static-prompt-refresh-interval-minutes + - name: RESGRID__TtsConfig__StaticPromptAdminKey + valueFrom: + secretKeyRef: + name: resgrid-tts-secrets + key: tts-static-prompt-admin-key + - name: RESGRID__CacheConfig__RedisConnectionString + valueFrom: + secretKeyRef: + name: resgrid-tts-secrets + key: tts-redis-connection-string + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 2 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + volumeMounts: + - name: temp-storage + mountPath: /tmp/resgrid-tts + volumes: + - name: temp-storage + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: resgrid-tts +spec: + selector: + app: resgrid-tts + ports: + - name: http + port: 80 + targetPort: http diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index e9ddfe7f..a5e5010e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json; +using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; @@ -242,6 +243,8 @@ public async Task Settings() var mapHideUnavailable = await _departmentSettingsService.GetBigBoardHideUnavailableDepartmentAsync(DepartmentId); var activeCallRssKey = await _departmentSettingsService.GetRssKeyForDepartmentAsync(DepartmentId); model.DisableAutoAvailable = await _departmentSettingsService.GetDisableAutoAvailableForDepartmentAsync(DepartmentId); + model.TtsLanguage = await _departmentSettingsService.GetTtsLanguageForDepartmentAsync(DepartmentId); + model.TtsLanguages = BuildTtsLanguageSelectList(model.TtsLanguage); var personnelSortOrder = await _departmentSettingsService.GetDepartmentPersonnelSortOrderAsync(DepartmentId); model.PersonnelSort = (int)personnelSortOrder; @@ -399,6 +402,8 @@ public async Task Settings(DepartmentSettingsModel model, IFormCo CallSortOrders callSortTypes = CallSortOrders.Default; model.CallSortTypes = callSortTypes.ToSelectListInt(); + model.TtsLanguage = NormalizeTtsLanguage(model.TtsLanguage); + model.TtsLanguages = BuildTtsLanguageSelectList(model.TtsLanguage); var staffingLevels = await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(DepartmentId); if (staffingLevels == null) @@ -495,6 +500,11 @@ public async Task Settings(DepartmentSettingsModel model, IFormCo ModelState.AddModelError("TimeToResetStatus", "If you want to reset status levels you need to supply a time to reset them."); } + if (!IsSupportedTtsLanguage(model.TtsLanguage)) + { + ModelState.AddModelError(nameof(model.TtsLanguage), "TTS language must be one of the supported eSpeak-NG languages."); + } + if (!String.IsNullOrWhiteSpace(model.MapCenterGpsCoordinatesLatitude) && !LocationHelpers.IsValidLatitude(model.MapCenterGpsCoordinatesLatitude)) { ModelState.AddModelError("MapCenterGpsCoordinatesLatitude", "Map Center Latitude value seems invalid, MUST be decimal format."); @@ -528,6 +538,8 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.Ma cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.DisableAutoAvailable.ToString(), DepartmentSettingTypes.DisabledAutoAvailable, cancellationToken); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.TtsLanguage, DepartmentSettingTypes.TtsLanguage, + cancellationToken); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, model.PersonnelSort.ToString(), DepartmentSettingTypes.PersonnelSortOrder, cancellationToken); @@ -649,6 +661,60 @@ await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, newAddre return View(model); } + private static SelectList BuildTtsLanguageSelectList(string selectedLanguage) + { + var normalizedSelectedLanguage = NormalizeTtsLanguage(selectedLanguage); + var languages = EspeakVoiceCatalog.Voices + .Select(x => new SelectListItem + { + Text = x.DisplayName, + Value = x.Identifier + }) + .ToList(); + + if (!string.IsNullOrWhiteSpace(normalizedSelectedLanguage) && + languages.All(x => !string.Equals(x.Value, normalizedSelectedLanguage, StringComparison.OrdinalIgnoreCase))) + { + languages.Insert(0, new SelectListItem + { + Text = EspeakVoiceCatalog.GetDisplayName(normalizedSelectedLanguage), + Value = normalizedSelectedLanguage + }); + } + + return new SelectList(languages, "Value", "Text", normalizedSelectedLanguage); + } + + private static string NormalizeTtsLanguage(string ttsLanguage) + { + if (EspeakVoiceCatalog.TryNormalizeIdentifier(ttsLanguage, out var normalizedLanguage)) + return normalizedLanguage; + + if (!string.IsNullOrWhiteSpace(ttsLanguage)) + return ttsLanguage.Trim(); + + return GetConfiguredDefaultTtsLanguage(); + } + + private static bool IsSupportedTtsLanguage(string ttsLanguage) + { + if (EspeakVoiceCatalog.TryNormalizeIdentifier(ttsLanguage, out _)) + return true; + + return string.Equals(NormalizeTtsLanguage(ttsLanguage), GetConfiguredDefaultTtsLanguage(), StringComparison.OrdinalIgnoreCase); + } + + private static string GetConfiguredDefaultTtsLanguage() + { + if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedLanguage)) + return normalizedLanguage; + + if (!string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice)) + return TtsConfig.DefaultVoice.Trim(); + + return EspeakVoiceCatalog.DefaultIdentifier; + } + [HttpGet] [Authorize(Policy = ResgridResources.Department_Update)] public async Task Api() diff --git a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs index f05336d0..acb2bfd8 100644 --- a/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/DepartmentSettingsModel.cs @@ -28,6 +28,8 @@ public class DepartmentSettingsModel : BaseUserModel public bool MapHideUnavailable { get; set; } public string ActiveCallRssKey { get; set; } public bool DisableAutoAvailable { get; set; } + public string TtsLanguage { get; set; } + public SelectList TtsLanguages { get; set; } public bool EnableStaffingReset { get; set; } public string TimeToResetStaffing { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml index a042c9c8..a6232c66 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Department/Settings.cshtml @@ -79,6 +79,13 @@ @localizer["DisableAutoAvailableHelp"] +
+ +
+ + @localizer["TtsLanguageHelp"] +
+

diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js index c282f7aa..6080be87 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.addArchivedCall.js @@ -297,10 +297,10 @@ var resgrid; $('#rolesGrid thead th:first').html(''); }); - $('#checkAllPersonnel').on('click', function () { $('#personnelGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllGroups').on('click', function () { $('#groupsGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllUnits').on('click', function () { $('#unitsGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllRoles').on('click', function () { $('#rolesGrid').find(':checkbox').prop('checked', this.checked); }); + $('#personnelGrid').on('click', '#checkAllPersonnel', function () { $('#personnelGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#groupsGrid').on('click', '#checkAllGroups', function () { $('#groupsGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#unitsGrid').on('click', '#checkAllUnits', function () { $('#unitsGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#rolesGrid').on('click', '#checkAllRoles', function () { $('#rolesGrid').find('tbody :checkbox').prop('checked', this.checked); }); $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { if (e.target && e.target.textContent === "Personnel") { personnelTable.columns.adjust(); } else if (e.target && e.target.textContent === "Groups") { groupsTable.columns.adjust(); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js index 8caa0436..13e9a0b9 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.editcall.js @@ -316,10 +316,10 @@ var resgrid; if (initialDrawCount >= totalTables) { resgrid.dispatch.editcall.updateDispatchedEntities(); } }); - $('#checkAllPersonnel').on('click', function () { $('#personnelGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllGroups').on('click', function () { $('#groupsGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllUnits').on('click', function () { $('#unitsGrid').find(':checkbox').prop('checked', this.checked); }); - $('#checkAllRoles').on('click', function () { $('#rolesGrid').find(':checkbox').prop('checked', this.checked); }); + $('#personnelGrid').on('click', '#checkAllPersonnel', function () { $('#personnelGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#groupsGrid').on('click', '#checkAllGroups', function () { $('#groupsGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#unitsGrid').on('click', '#checkAllUnits', function () { $('#unitsGrid').find('tbody :checkbox').prop('checked', this.checked); }); + $('#rolesGrid').on('click', '#checkAllRoles', function () { $('#rolesGrid').find('tbody :checkbox').prop('checked', this.checked); }); $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { if (e.target && e.target.textContent === "Personnel") { personnelTable.columns.adjust(); } else if (e.target && e.target.textContent === "Groups") { groupsTable.columns.adjust(); } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js index 3d9fd203..0f4cb8f7 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dispatch/resgrid.dispatch.newcall.js @@ -326,14 +326,14 @@ var resgrid; } newcall.fillCallTemplate = fillCallTemplate; - $('#checkAllPersonnel').on('click', function () { - $('#personnelGrid').find(':checkbox').prop('checked', this.checked); + $('#personnelGrid').on('click', '#checkAllPersonnel', function () { + $('#personnelGrid').find('tbody :checkbox').prop('checked', this.checked); }); - $('#checkAllGroups').on('click', function () { - $('#groupsGrid').find(':checkbox').prop('checked', this.checked); + $('#groupsGrid').on('click', '#checkAllGroups', function () { + $('#groupsGrid').find('tbody :checkbox').prop('checked', this.checked); }); - $('#checkAllRoles').on('click', function () { - $('#rolesGrid').find(':checkbox').prop('checked', this.checked); + $('#rolesGrid').on('click', '#checkAllRoles', function () { + $('#rolesGrid').find('tbody :checkbox').prop('checked', this.checked); }); $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { // DataTables adjusts itself; trigger resize for any hidden columns @@ -419,7 +419,7 @@ var resgrid; } newcall.refreshPersonnelGrid = refreshPersonnelGrid; function checkAllUnits(gridName, item) { - $('#' + gridName).find(':checkbox').trigger('click');//.prop('checked', item.value); + $('#' + gridName).find(':checkbox').prop('checked', item.checked); } newcall.checkAllUnits = checkAllUnits; function checkForProtocols() { diff --git a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css index f884dd39..0b0510b5 100644 --- a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css +++ b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.css @@ -1 +1,1850 @@ -@charset "UTF-8";.rg-element-root{box-sizing:border-box}.rg-card{background:#fff;border:1px solid #e5e7eb;border-radius:6px;box-shadow:0 1px 2px #0f172a14}.rg-loading{display:flex;min-height:120px;align-items:center;justify-content:center;color:#4b5563;font-size:14px}.rg-loading__stack{display:inline-flex;align-items:center;gap:12px}.rg-loading__spinner{width:18px;height:18px;border-radius:50%;border:2px solid rgba(59,130,246,.2);border-top-color:#2563eb;animation:rg-spin .8s linear infinite}.rg-error{padding:12px 14px;color:#991b1b;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;font-size:13px}@keyframes rg-spin{to{transform:rotate(360deg)}}.rg-map{font-size:13px;color:#111827}.rg-map__toolbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px}.rg-map__toolbar-section{display:flex;flex-wrap:wrap;align-items:center;gap:12px}.rg-map__search{width:220px;max-width:100%;padding:7px 10px;border:1px solid #cbd5e1;border-radius:6px;outline:none}.rg-map__search:focus{border-color:#2563eb;box-shadow:0 0 0 3px #2563eb26}.rg-map__checkbox,.rg-map__layer-toggle{display:inline-flex;align-items:center;gap:6px;font-weight:400}.rg-map__message{margin-bottom:8px}.rg-map__viewport{position:relative;width:100%;min-height:320px;overflow:hidden;border:1px solid #d1d5db;border-radius:6px;background:#f8fafc}.rg-map__canvas,.rg-map__canvas .leaflet-container,.rg-map__canvas .mapboxgl-map{width:100%;height:100%}.rg-map__layers{position:absolute;top:12px;right:12px;z-index:450;width:240px;max-width:calc(100% - 24px);padding:12px}.rg-map__layers-title{margin-bottom:8px;font-weight:600}.rg-map__layer-list{display:flex;flex-direction:column;gap:6px}.rg-map__overlay{position:absolute;inset:0;z-index:500;background:#ffffffb8}.rg-map__footer{display:flex;justify-content:flex-end;padding-top:8px;color:#6b7280;font-size:12px}.rg-map__tooltip{padding:2px 6px;border:1px solid #111827;color:#111827;background:#fff;font-size:11px;font-weight:600}.rg-map__tooltip:before{border-top-color:#111827}.rg-map__marker{display:inline-flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer}.rg-map__marker-icon{width:32px;height:37px;object-fit:contain;pointer-events:none}.rg-map__marker-label{max-width:180px;padding:2px 6px;border:1px solid #111827;border-radius:4px;color:#111827;background:#fffffff5;font-size:11px;font-weight:600;line-height:1.2;text-align:center;white-space:nowrap}@media (max-width: 900px){.rg-map__toolbar{align-items:stretch}.rg-map__layers{position:static;width:auto;max-width:none;margin:12px}}.rbc-btn{color:inherit;font:inherit;margin:0}button.rbc-btn{overflow:visible;text-transform:none;-webkit-appearance:button;-moz-appearance:button;appearance:button;cursor:pointer}button[disabled].rbc-btn{cursor:not-allowed}button.rbc-input::-moz-focus-inner{border:0;padding:0}.rbc-calendar{-webkit-box-sizing:border-box;box-sizing:border-box;height:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.rbc-m-b-negative-3{margin-bottom:-3px}.rbc-h-full{height:100%}.rbc-calendar *,.rbc-calendar *:before,.rbc-calendar *:after{-webkit-box-sizing:inherit;box-sizing:inherit}.rbc-abs-full,.rbc-row-bg{overflow:hidden;position:absolute;inset:0}.rbc-ellipsis,.rbc-show-more,.rbc-row-segment .rbc-event-content,.rbc-event-label{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rbc-rtl{direction:rtl}.rbc-off-range{color:#999}.rbc-off-range-bg{background:#e6e6e6}.rbc-header{overflow:hidden;-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;text-overflow:ellipsis;white-space:nowrap;padding:0 3px;text-align:center;vertical-align:middle;font-weight:700;font-size:90%;min-height:0;border-bottom:1px solid #ddd}.rbc-header+.rbc-header{border-left:1px solid #ddd}.rbc-rtl .rbc-header+.rbc-header{border-left-width:0;border-right:1px solid #ddd}.rbc-header>a,.rbc-header>a:active,.rbc-header>a:visited{color:inherit;text-decoration:none}.rbc-button-link{color:inherit;background:none;margin:0;padding:0;border:none;cursor:pointer;-webkit-user-select:text;-moz-user-select:text;-ms-user-select:text;user-select:text}.rbc-row-content{position:relative;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none;z-index:4}.rbc-row-content-scrollable{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;height:100%}.rbc-row-content-scrollable .rbc-row-content-scroll-container{height:100%;overflow-y:scroll;-ms-overflow-style:none;scrollbar-width:none}.rbc-row-content-scrollable .rbc-row-content-scroll-container::-webkit-scrollbar{display:none}.rbc-today{background-color:#eaf6ff}.rbc-toolbar{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;margin-bottom:10px;font-size:16px}.rbc-toolbar .rbc-toolbar-label{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;padding:0 10px;text-align:center}.rbc-toolbar button{color:#373a3c;display:inline-block;margin:0;text-align:center;vertical-align:middle;background:none;background-image:none;border:1px solid #ccc;padding:.375rem 1rem;border-radius:4px;line-height:normal;white-space:nowrap}.rbc-toolbar button:active,.rbc-toolbar button.rbc-active{background-image:none;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px #00000020;background-color:#e6e6e6;border-color:#adadad}.rbc-toolbar button:active:hover,.rbc-toolbar button:active:focus,.rbc-toolbar button.rbc-active:hover,.rbc-toolbar button.rbc-active:focus{color:#373a3c;background-color:#d4d4d4;border-color:#8c8c8c}.rbc-toolbar button:focus{color:#373a3c;background-color:#e6e6e6;border-color:#adadad}.rbc-toolbar button:hover{color:#373a3c;cursor:pointer;background-color:#e6e6e6;border-color:#adadad}.rbc-btn-group{display:inline-block;white-space:nowrap}.rbc-btn-group>button:first-child:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.rbc-btn-group>button:last-child:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.rbc-rtl .rbc-btn-group>button:first-child:not(:last-child){border-radius:0 4px 4px 0}.rbc-rtl .rbc-btn-group>button:last-child:not(:first-child){border-radius:4px 0 0 4px}.rbc-btn-group>button:not(:first-child):not(:last-child){border-radius:0}.rbc-btn-group button+button{margin-left:-1px}.rbc-rtl .rbc-btn-group button+button{margin-left:0;margin-right:-1px}.rbc-btn-group+.rbc-btn-group,.rbc-btn-group+button{margin-left:10px}@media (max-width: 767px){.rbc-toolbar{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}}.rbc-event,.rbc-day-slot .rbc-background-event{border:none;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-box-shadow:none;box-shadow:none;margin:0;padding:2px 5px;background-color:#3174ad;border-radius:5px;color:#fff;cursor:pointer;width:100%;text-align:left}.rbc-slot-selecting .rbc-event,.rbc-slot-selecting .rbc-day-slot .rbc-background-event,.rbc-day-slot .rbc-slot-selecting .rbc-background-event{cursor:inherit;pointer-events:none}.rbc-event.rbc-selected,.rbc-day-slot .rbc-selected.rbc-background-event{background-color:#265985}.rbc-event:focus,.rbc-day-slot .rbc-background-event:focus{outline:5px auto #3b99fc}.rbc-event-label{font-size:80%}.rbc-event-overlaps{-webkit-box-shadow:-1px 1px 5px 0px rgba(51,51,51,.5);box-shadow:-1px 1px 5px #33333380}.rbc-event-continues-prior{border-top-left-radius:0;border-bottom-left-radius:0}.rbc-event-continues-after{border-top-right-radius:0;border-bottom-right-radius:0}.rbc-event-continues-earlier{border-top-left-radius:0;border-top-right-radius:0}.rbc-event-continues-later{border-bottom-left-radius:0;border-bottom-right-radius:0}.rbc-row{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.rbc-row-segment{padding:0 1px 1px}.rbc-selected-cell{background-color:#0000001a}.rbc-show-more{background-color:#ffffff4d;z-index:4;font-weight:700;font-size:85%;height:auto;line-height:normal;color:#3174ad}.rbc-show-more:hover,.rbc-show-more:focus{color:#265985}.rbc-month-view{position:relative;border:1px solid #ddd;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0;width:100%;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none;height:100%}.rbc-month-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.rbc-month-row{display:-webkit-box;display:-ms-flexbox;display:flex;position:relative;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0;-ms-flex-preferred-size:0px;flex-basis:0px;overflow:hidden;height:100%}.rbc-month-row+.rbc-month-row{border-top:1px solid #ddd}.rbc-date-cell{-webkit-box-flex:1;-ms-flex:1 1 0px;flex:1 1 0;min-width:0;padding-right:5px;text-align:right}.rbc-date-cell.rbc-now{font-weight:700}.rbc-date-cell>a,.rbc-date-cell>a:active,.rbc-date-cell>a:visited{color:inherit;text-decoration:none}.rbc-row-bg{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0;overflow:hidden;right:1px}.rbc-day-bg{-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%}.rbc-day-bg+.rbc-day-bg{border-left:1px solid #ddd}.rbc-rtl .rbc-day-bg+.rbc-day-bg{border-left-width:0;border-right:1px solid #ddd}.rbc-overlay{position:absolute;z-index:5;border:1px solid #e5e5e5;background-color:#fff;-webkit-box-shadow:0 5px 15px rgba(0,0,0,.25);box-shadow:0 5px 15px #00000040;padding:10px}.rbc-overlay>*+*{margin-top:1px}.rbc-overlay-header{border-bottom:1px solid #e5e5e5;margin:-10px -10px 5px;padding:2px 10px}.rbc-agenda-view{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0;overflow:auto}.rbc-agenda-view table.rbc-agenda-table{width:100%;border:1px solid #ddd;border-spacing:0;border-collapse:collapse}.rbc-agenda-view table.rbc-agenda-table tbody>tr>td{padding:5px 10px;vertical-align:top}.rbc-agenda-view table.rbc-agenda-table .rbc-agenda-time-cell{padding-left:15px;padding-right:15px;text-transform:lowercase}.rbc-agenda-view table.rbc-agenda-table tbody>tr>td+td{border-left:1px solid #ddd}.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody>tr>td+td{border-left-width:0;border-right:1px solid #ddd}.rbc-agenda-view table.rbc-agenda-table tbody>tr+tr{border-top:1px solid #ddd}.rbc-agenda-view table.rbc-agenda-table thead>tr>th{padding:3px 5px;text-align:left;border-bottom:1px solid #ddd}.rbc-rtl .rbc-agenda-view table.rbc-agenda-table thead>tr>th{text-align:right}.rbc-agenda-time-cell{text-transform:lowercase}.rbc-agenda-time-cell .rbc-continues-after:after{content:" »"}.rbc-agenda-time-cell .rbc-continues-prior:before{content:"« "}.rbc-agenda-date-cell,.rbc-agenda-time-cell{white-space:nowrap}.rbc-agenda-event-cell{width:100%}.rbc-time-column{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;min-height:100%}.rbc-time-column .rbc-timeslot-group{-webkit-box-flex:1;-ms-flex:1;flex:1}.rbc-timeslot-group{border-bottom:1px solid #ddd;min-height:40px;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-flow:column nowrap;flex-flow:column nowrap}.rbc-time-gutter,.rbc-header-gutter{-webkit-box-flex:0;-ms-flex:none;flex:none}.rbc-label{padding:0 5px}.rbc-day-slot{position:relative}.rbc-day-slot .rbc-events-container{inset:0;position:absolute;margin-right:10px}.rbc-day-slot .rbc-events-container.rbc-rtl{left:10px;right:0}.rbc-day-slot .rbc-event,.rbc-day-slot .rbc-background-event{border:1px solid #265985;display:-webkit-box;display:-ms-flexbox;display:flex;max-height:100%;min-height:20px;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-flow:column wrap;flex-flow:column wrap;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;overflow:hidden;position:absolute}.rbc-day-slot .rbc-background-event{opacity:.75}.rbc-day-slot .rbc-event-label{-webkit-box-flex:0;-ms-flex:none;flex:none;padding-right:5px;width:auto}.rbc-day-slot .rbc-event-content{width:100%;-webkit-box-flex:1;-ms-flex:1 1 0px;flex:1 1 0;word-wrap:break-word;line-height:1;height:100%;min-height:1em}.rbc-day-slot .rbc-time-slot{border-top:1px solid #f7f7f7}.rbc-time-view-resources .rbc-time-gutter,.rbc-time-view-resources .rbc-time-header-gutter{position:sticky;left:0;background-color:#fff;border-right:1px solid #ddd;z-index:10;margin-right:-1px}.rbc-time-view-resources .rbc-time-header{overflow:hidden}.rbc-time-view-resources .rbc-time-header-content{min-width:auto;-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0;-ms-flex-preferred-size:0px;flex-basis:0px}.rbc-time-view-resources .rbc-time-header-cell-single-day{display:none}.rbc-time-view-resources .rbc-day-slot{min-width:140px}.rbc-time-view-resources .rbc-header,.rbc-time-view-resources .rbc-day-bg{width:140px;-webkit-box-flex:1;-ms-flex:1 1 0px;flex:1 1 0;-ms-flex-preferred-size:0 px;flex-basis:0 px}.rbc-time-header-content+.rbc-time-header-content{margin-left:-1px}.rbc-time-slot{-webkit-box-flex:1;-ms-flex:1 0 0px;flex:1 0 0}.rbc-time-slot.rbc-now{font-weight:700}.rbc-day-header{text-align:center}.rbc-slot-selection{z-index:10;position:absolute;background-color:#00000080;color:#fff;font-size:75%;width:100%;padding:3px}.rbc-slot-selecting{cursor:move}.rbc-time-view{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-ms-flex:1;flex:1;width:100%;border:1px solid #ddd;min-height:0}.rbc-time-view .rbc-time-gutter{white-space:nowrap;text-align:right}.rbc-time-view .rbc-allday-cell{-webkit-box-sizing:content-box;box-sizing:content-box;width:100%;height:100%;position:relative}.rbc-time-view .rbc-allday-cell+.rbc-allday-cell{border-left:1px solid #ddd}.rbc-time-view .rbc-allday-events{position:relative;z-index:4}.rbc-time-view .rbc-row{-webkit-box-sizing:border-box;box-sizing:border-box;min-height:20px}.rbc-time-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.rbc-time-header.rbc-overflowing{border-right:1px solid #ddd}.rbc-rtl .rbc-time-header.rbc-overflowing{border-right-width:0;border-left:1px solid #ddd}.rbc-time-header>.rbc-row:first-child{border-bottom:1px solid #ddd}.rbc-time-header>.rbc-row.rbc-row-resource{border-bottom:1px solid #ddd}.rbc-time-header-cell-single-day{display:none}.rbc-time-header-content{-webkit-box-flex:1;-ms-flex:1;flex:1;display:-webkit-box;display:-ms-flexbox;display:flex;min-width:0;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;border-left:1px solid #ddd}.rbc-rtl .rbc-time-header-content{border-left-width:0;border-right:1px solid #ddd}.rbc-time-header-content>.rbc-row.rbc-row-resource{border-bottom:1px solid #ddd;-ms-flex-negative:0;flex-shrink:0}.rbc-time-content{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 0 0%;flex:1 0 0%;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;width:100%;border-top:2px solid #ddd;overflow-y:auto;position:relative}.rbc-time-content>.rbc-time-gutter{-webkit-box-flex:0;-ms-flex:none;flex:none}.rbc-time-content>*+*>*{border-left:1px solid #ddd}.rbc-rtl .rbc-time-content>*+*>*{border-left-width:0;border-right:1px solid #ddd}.rbc-time-content>.rbc-day-slot{width:100%;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-user-select:none}.rbc-current-time-indicator{position:absolute;z-index:3;left:0;right:0;height:1px;background-color:#74ad31;pointer-events:none}.rbc-resource-grouping.rbc-time-header-content{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.rbc-resource-grouping .rbc-row .rbc-header{width:141px}.rg-shifts{color:#111827;font-size:13px}.rg-shifts__calendar,.rg-shifts .rbc-calendar{min-height:760px}.rg-shifts .rbc-toolbar{gap:12px;margin-bottom:18px}.rg-shifts .rbc-toolbar button{border:1px solid #2563eb;border-radius:4px;background:#2563eb;color:#fff;padding:6px 12px}.rg-shifts .rbc-toolbar button:hover,.rg-shifts .rbc-toolbar button:focus{border-color:#1d4ed8;background:#1d4ed8;color:#fff}.rg-shifts .rbc-toolbar button.rbc-active{border-color:#1e40af;background:#1e40af;color:#fff}.rg-shifts .rbc-month-view,.rg-shifts .rbc-time-view{overflow:hidden;border:1px solid #d1d5db;border-radius:6px}.rg-shifts .rbc-header{padding:8px 0;font-weight:600}.rg-shifts .rbc-today{background:#eff6ff}.rg-shifts .rbc-off-range-bg{background:#f8fafc}.rg-shifts .rbc-event{box-shadow:none}.rg-omnibar{display:flex;flex-direction:column;gap:12px;max-width:640px;color:#111827;font-size:13px}.rg-omnibar__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.rg-omnibar__title{font-size:18px;font-weight:600}.rg-omnibar__count{color:#6b7280;font-size:12px}.rg-omnibar__input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:6px;outline:none}.rg-omnibar__input:focus{border-color:#2563eb;box-shadow:0 0 0 3px #2563eb26}.rg-omnibar__results{display:flex;flex-direction:column;gap:8px}.rg-omnibar__item{display:flex;width:100%;flex-direction:column;gap:4px;padding:12px;border:1px solid #dbe2ea;border-radius:6px;background:#fff;text-align:left;transition:border-color .15s ease,background .15s ease}.rg-omnibar__item:hover,.rg-omnibar__item--active{border-color:#2563eb;background:#eff6ff}.rg-omnibar__item-label{font-size:14px;font-weight:600}.rg-omnibar__item-description,.rg-omnibar__empty{color:#6b7280}.mapboxgl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.mapboxgl-canvas{left:0;position:absolute;top:0}.mapboxgl-map:-webkit-full-screen{height:100%;width:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom,.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-left,.mapboxgl-ctrl-right,.mapboxgl-ctrl-top,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.mapboxgl-ctrl-top-left{left:0;top:0}.mapboxgl-ctrl-top{left:50%;top:0;transform:translate(-50%)}.mapboxgl-ctrl-top-right{right:0;top:0}.mapboxgl-ctrl-right{right:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl-bottom{bottom:0;left:50%;transform:translate(-50%)}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-left{left:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.mapboxgl-ctrl-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-bottom .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.mapboxgl-ctrl-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px #0000001a}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{background-color:initial;border:0;box-sizing:border-box;cursor:pointer;display:block;height:32px;outline:none;overflow:hidden;padding:0;width:32px}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:initial}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:#eee}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-up .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 11.7071C4.68342 12.0976 5.31658 12.0976 5.70711 11.7071L9 8.41421L12.2929 11.7071C12.6834 12.0976 13.3166 12.0976 13.7071 11.7071C14.0976 11.3166 14.0976 10.6834 13.7071 10.2929L9.70711 6.29289C9.31658 5.90237 8.68342 5.90237 8.29289 6.29289L4.29289 10.2929C3.90237 10.6834 3.90237 11.3166 4.29289 11.7071Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-down .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 6.29289C4.68342 5.90237 5.31658 5.90237 5.70711 6.29289L9 9.58579L12.2929 6.29289C12.6834 5.90237 13.3166 5.90237 13.7071 6.29289C14.0976 6.68342 14.0976 7.31658 13.7071 7.70711L9.70711 11.7071C9.31658 12.0976 8.68342 12.0976 8.29289 11.7071L4.29289 7.70711C3.90237 7.31658 3.90237 6.68342 4.29289 6.29289Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23fff' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23000' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:initial;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:#ffffff80;margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{background-color:#fff;border-radius:12px;box-sizing:initial;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:#0000000d}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0;top:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0;top:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:#000000bf;text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:#ffffffbf;border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.mapboxgl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{border:10px solid #0000;height:0;width:0;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.mapboxgl-popup-close-button{background-color:initial;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.mapboxgl-popup-close-button:hover{background-color:#eee}.mapboxgl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px #0000001a;padding:10px 10px 15px;pointer-events:auto;position:relative}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{left:0;opacity:1;position:absolute;top:0;transition:opacity .2s;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.mapboxgl-user-location-dot:before{animation:mapboxgl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.mapboxgl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px #00000059;box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{height:0;width:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-bottom:7.5px solid #4aa1eb;content:"";position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid #0000;transform:translateY(-28px) skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid #0000;transform:translate(7.5px,-28px) skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{align-items:center;background:#000000b3;color:#fff;display:flex;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;height:100%;justify-content:center;left:0;opacity:0;pointer-events:none;position:absolute;text-align:center;top:0;transition:opacity .75s ease-in-out;transition-delay:1s;width:100%}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button{font-size:16px;font-weight:700;text-align:center}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected{background-color:#ccc;color:#000}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected:hover{background-color:#ccc}.leaflet-pane,.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile-container,.leaflet-pane>svg,.leaflet-pane>canvas,.leaflet-zoom-box,.leaflet-image-layer,.leaflet-layer{position:absolute;left:0;top:0}.leaflet-container{overflow:hidden}.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow{-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:transparent}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{width:1600px;height:1600px;-webkit-transform-origin:0 0}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer,.leaflet-container .leaflet-tile{max-width:none!important;max-height:none!important;width:auto;padding:0}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:rgba(51,181,229,.4)}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{width:0;height:0;-moz-box-sizing:border-box;box-sizing:border-box;z-index:800}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{position:relative;z-index:800;pointer-events:visiblePainted;pointer-events:auto}.leaflet-top,.leaflet-bottom{position:absolute;z-index:1000;pointer-events:none}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;-webkit-transition:opacity .2s linear;-moz-transition:opacity .2s linear;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{-webkit-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-zoom-anim .leaflet-tile,.leaflet-pan-anim .leaflet-tile{-webkit-transition:none;-moz-transition:none;transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-popup-pane,.leaflet-control{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-image-layer,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-marker-icon.leaflet-interactive,.leaflet-image-layer.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{background:#ddd;outline-offset:1px}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{border:2px dotted #38f;background:#ffffff80}.leaflet-container{font-family:Helvetica Neue,Arial,Helvetica,sans-serif;font-size:12px;font-size:.75rem;line-height:1.5}.leaflet-bar{box-shadow:0 1px 5px #000000a6;border-radius:4px}.leaflet-bar a{background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;display:block;text-align:center;text-decoration:none;color:#000}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50% 50%;background-repeat:no-repeat;display:block}.leaflet-bar a:hover,.leaflet-bar a:focus{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:4px;border-bottom:none}.leaflet-bar a.leaflet-disabled{cursor:default;background-color:#f4f4f4;color:#bbb}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-left-radius:2px;border-bottom-right-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{font:700 18px Lucida Console,Monaco,monospace;text-indent:1px}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{box-shadow:0 1px 5px #0006;background:#fff;border-radius:5px}.leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{padding:6px 10px 6px 6px;color:#333;background:#fff}.leaflet-control-layers-scrollbar{overflow-y:scroll;overflow-x:hidden;padding-right:5px}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{display:block;font-size:13px;font-size:1.08333em}.leaflet-control-layers-separator{height:0;border-top:1px solid #ddd;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution{background:#fff;background:#fffc;margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{padding:0 5px;color:#333;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:hover,.leaflet-control-attribution a:focus{text-decoration:underline}.leaflet-attribution-flag{display:inline!important;vertical-align:baseline!important;width:1em;height:.6669em}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{border:2px solid #777;border-top:none;line-height:1.1;padding:2px 5px 1px;white-space:nowrap;-moz-box-sizing:border-box;box-sizing:border-box;background:#fffc;text-shadow:1px 1px #fff}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{box-shadow:none}.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{border:2px solid rgba(0,0,0,.2);background-clip:padding-box}.leaflet-popup{position:absolute;text-align:center;margin-bottom:20px}.leaflet-popup-content-wrapper{padding:1px;text-align:left;border-radius:12px}.leaflet-popup-content{margin:13px 24px 13px 20px;line-height:1.3;font-size:13px;font-size:1.08333em;min-height:1px}.leaflet-popup-content p{margin:1.3em 0}.leaflet-popup-tip-container{width:40px;height:20px;position:absolute;left:50%;margin-top:-1px;margin-left:-20px;overflow:hidden;pointer-events:none}.leaflet-popup-tip{width:17px;height:17px;padding:1px;margin:-10px auto 0;pointer-events:auto;-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{background:#fff;color:#333;box-shadow:0 3px 14px #0006}.leaflet-container a.leaflet-popup-close-button{position:absolute;top:0;right:0;border:none;text-align:center;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;color:#757575;text-decoration:none;background:transparent}.leaflet-container a.leaflet-popup-close-button:hover,.leaflet-container a.leaflet-popup-close-button:focus{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{width:24px;margin:0 auto;-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";filter:progid:DXImageTransform.Microsoft.Matrix(M11=.70710678,M12=.70710678,M21=-.70710678,M22=.70710678)}.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{position:absolute;padding:6px;background-color:#fff;border:1px solid #fff;border-radius:3px;color:#222;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;pointer-events:none;box-shadow:0 1px 3px #0006}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{position:absolute;pointer-events:none;border:6px solid transparent;background:transparent;content:""}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{left:50%;margin-left:-6px}.leaflet-tooltip-top:before{bottom:0;margin-bottom:-12px;border-top-color:#fff}.leaflet-tooltip-bottom:before{top:0;margin-top:-12px;margin-left:-6px;border-bottom-color:#fff}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{top:50%;margin-top:-6px}.leaflet-tooltip-left:before{right:0;margin-right:-12px;border-left-color:#fff}.leaflet-tooltip-right:before{left:0;margin-left:-12px;border-right-color:#fff}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}} +@charset "UTF-8";.rg-element-root { + box-sizing: border-box; +} + +.rg-card { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} + +.rg-loading { + display: flex; + min-height: 120px; + align-items: center; + justify-content: center; + color: #4b5563; + font-size: 14px; +} + +.rg-loading__stack { + display: inline-flex; + align-items: center; + gap: 12px; +} + +.rg-loading__spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(59, 130, 246, 0.2); + border-top-color: #2563eb; + animation: rg-spin 0.8s linear infinite; +} + +.rg-error { + padding: 12px 14px; + color: #991b1b; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + font-size: 13px; +} + +@keyframes rg-spin { + to { + transform: rotate(360deg); + } +} +.rg-map { + font-size: 13px; + color: #111827; +} + +.rg-map__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.rg-map__toolbar-section { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.rg-map__search { + width: 220px; + max-width: 100%; + padding: 7px 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + outline: none; +} + +.rg-map__search:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.rg-map__checkbox, +.rg-map__layer-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 400; +} + +.rg-map__message { + margin-bottom: 8px; +} + +.rg-map__viewport { + position: relative; + width: 100%; + min-height: 320px; + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #f8fafc; +} + +.rg-map__canvas { + width: 100%; + height: 100%; +} + +.rg-map__canvas .leaflet-container { + width: 100%; + height: 100%; +} + +.rg-map__canvas .mapboxgl-map { + width: 100%; + height: 100%; +} + +.rg-map__layers { + position: absolute; + top: 12px; + right: 12px; + z-index: 450; + width: 240px; + max-width: calc(100% - 24px); + padding: 12px; +} + +.rg-map__layers-title { + margin-bottom: 8px; + font-weight: 600; +} + +.rg-map__layer-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.rg-map__overlay { + position: absolute; + inset: 0; + z-index: 500; + background: rgba(255, 255, 255, 0.72); +} + +.rg-map__footer { + display: flex; + justify-content: flex-end; + padding-top: 8px; + color: #6b7280; + font-size: 12px; +} + +.rg-map__tooltip { + padding: 2px 6px; + border: 1px solid #111827; + color: #111827; + background: #ffffff; + font-size: 11px; + font-weight: 600; +} + +.rg-map__tooltip::before { + border-top-color: #111827; +} + +.rg-map__marker { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.rg-map__marker-icon { + width: 32px; + height: 37px; + object-fit: contain; + pointer-events: none; +} + +.rg-map__marker-label { + max-width: 180px; + padding: 2px 6px; + border: 1px solid #111827; + border-radius: 4px; + color: #111827; + background: rgba(255, 255, 255, 0.96); + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-align: center; + white-space: nowrap; +} + +@media (max-width: 900px) { + .rg-map__toolbar { + align-items: stretch; + } + + .rg-map__layers { + position: static; + width: auto; + max-width: none; + margin: 12px; + } +} + +.rbc-btn { + color: inherit; + font: inherit; + margin: 0; +} + +button.rbc-btn { + overflow: visible; + text-transform: none; + -webkit-appearance: button; + -moz-appearance: button; + appearance: button; + cursor: pointer; +} + +button[disabled].rbc-btn { + cursor: not-allowed; +} + +button.rbc-input::-moz-focus-inner { + border: 0; + padding: 0; +} + +.rbc-calendar { + -webkit-box-sizing: border-box; + box-sizing: border-box; + height: 100%; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-align: stretch; + -ms-flex-align: stretch; + align-items: stretch; +} + +.rbc-m-b-negative-3 { + margin-bottom: -3px; +} + +.rbc-h-full { + height: 100%; +} + +.rbc-calendar *, +.rbc-calendar *:before, +.rbc-calendar *:after { + -webkit-box-sizing: inherit; + box-sizing: inherit; +} + +.rbc-abs-full, .rbc-row-bg { + overflow: hidden; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.rbc-ellipsis, .rbc-show-more, .rbc-row-segment .rbc-event-content, .rbc-event-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rbc-rtl { + direction: rtl; +} + +.rbc-off-range { + color: #999999; +} + +.rbc-off-range-bg { + background: #e6e6e6; +} + +.rbc-header { + overflow: hidden; + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 3px; + text-align: center; + vertical-align: middle; + font-weight: bold; + font-size: 90%; + min-height: 0; + border-bottom: 1px solid #ddd; +} +.rbc-header + .rbc-header { + border-left: 1px solid #ddd; +} +.rbc-rtl .rbc-header + .rbc-header { + border-left-width: 0; + border-right: 1px solid #ddd; +} +.rbc-header > a, .rbc-header > a:active, .rbc-header > a:visited { + color: inherit; + text-decoration: none; +} + +.rbc-button-link { + color: inherit; + background: none; + margin: 0; + padding: 0; + border: none; + cursor: pointer; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +.rbc-row-content { + position: relative; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + z-index: 4; +} + +.rbc-row-content-scrollable { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + height: 100%; +} +.rbc-row-content-scrollable .rbc-row-content-scroll-container { + height: 100%; + overflow-y: scroll; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + /* Hide scrollbar for Chrome, Safari and Opera */ +} +.rbc-row-content-scrollable .rbc-row-content-scroll-container::-webkit-scrollbar { + display: none; +} + +.rbc-today { + background-color: #eaf6ff; +} + +.rbc-toolbar { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin-bottom: 10px; + font-size: 16px; +} +.rbc-toolbar .rbc-toolbar-label { + -webkit-box-flex: 1; + -ms-flex-positive: 1; + flex-grow: 1; + padding: 0 10px; + text-align: center; +} +.rbc-toolbar button { + color: #373a3c; + display: inline-block; + margin: 0; + text-align: center; + vertical-align: middle; + background: none; + background-image: none; + border: 1px solid #ccc; + padding: 0.375rem 1rem; + border-radius: 4px; + line-height: normal; + white-space: nowrap; +} +.rbc-toolbar button:active, .rbc-toolbar button.rbc-active { + background-image: none; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + background-color: #e6e6e6; + border-color: #adadad; +} +.rbc-toolbar button:active:hover, .rbc-toolbar button:active:focus, .rbc-toolbar button.rbc-active:hover, .rbc-toolbar button.rbc-active:focus { + color: #373a3c; + background-color: #d4d4d4; + border-color: #8c8c8c; +} +.rbc-toolbar button:focus { + color: #373a3c; + background-color: #e6e6e6; + border-color: #adadad; +} +.rbc-toolbar button:hover { + color: #373a3c; + cursor: pointer; + background-color: #e6e6e6; + border-color: #adadad; +} + +.rbc-btn-group { + display: inline-block; + white-space: nowrap; +} +.rbc-btn-group > button:first-child:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.rbc-btn-group > button:last-child:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.rbc-rtl .rbc-btn-group > button:first-child:not(:last-child) { + border-radius: 4px; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.rbc-rtl .rbc-btn-group > button:last-child:not(:first-child) { + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.rbc-btn-group > button:not(:first-child):not(:last-child) { + border-radius: 0; +} +.rbc-btn-group button + button { + margin-left: -1px; +} +.rbc-rtl .rbc-btn-group button + button { + margin-left: 0; + margin-right: -1px; +} +.rbc-btn-group + .rbc-btn-group, .rbc-btn-group + button { + margin-left: 10px; +} + +@media (max-width: 767px) { + .rbc-toolbar { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + } +} +.rbc-event, .rbc-day-slot .rbc-background-event { + border: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: none; + box-shadow: none; + margin: 0; + padding: 2px 5px; + background-color: #3174ad; + border-radius: 5px; + color: #fff; + cursor: pointer; + width: 100%; + text-align: left; +} +.rbc-slot-selecting .rbc-event, .rbc-slot-selecting .rbc-day-slot .rbc-background-event, .rbc-day-slot .rbc-slot-selecting .rbc-background-event { + cursor: inherit; + pointer-events: none; +} +.rbc-event.rbc-selected, .rbc-day-slot .rbc-selected.rbc-background-event { + background-color: #265985; +} +.rbc-event:focus, .rbc-day-slot .rbc-background-event:focus { + outline: 5px auto #3b99fc; +} + +.rbc-event-label { + font-size: 80%; +} + +.rbc-event-overlaps { + -webkit-box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5); + box-shadow: -1px 1px 5px 0px rgba(51, 51, 51, 0.5); +} + +.rbc-event-continues-prior { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rbc-event-continues-after { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.rbc-event-continues-earlier { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.rbc-event-continues-later { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.rbc-row { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.rbc-row-segment { + padding: 0 1px 1px 1px; +} +.rbc-selected-cell { + background-color: rgba(0, 0, 0, 0.1); +} + +.rbc-show-more { + background-color: rgba(255, 255, 255, 0.3); + z-index: 4; + font-weight: bold; + font-size: 85%; + height: auto; + line-height: normal; + color: #3174ad; +} +.rbc-show-more:hover, .rbc-show-more:focus { + color: #265985; +} + +.rbc-month-view { + position: relative; + border: 1px solid #ddd; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + width: 100%; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + height: 100%; +} + +.rbc-month-header { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.rbc-month-row { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + position: relative; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + -ms-flex-preferred-size: 0px; + flex-basis: 0px; + overflow: hidden; + height: 100%; +} +.rbc-month-row + .rbc-month-row { + border-top: 1px solid #ddd; +} + +.rbc-date-cell { + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + min-width: 0; + padding-right: 5px; + text-align: right; +} +.rbc-date-cell.rbc-now { + font-weight: bold; +} +.rbc-date-cell > a, .rbc-date-cell > a:active, .rbc-date-cell > a:visited { + color: inherit; + text-decoration: none; +} + +.rbc-row-bg { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + overflow: hidden; + right: 1px; +} + +.rbc-day-bg { + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; +} +.rbc-day-bg + .rbc-day-bg { + border-left: 1px solid #ddd; +} +.rbc-rtl .rbc-day-bg + .rbc-day-bg { + border-left-width: 0; + border-right: 1px solid #ddd; +} + +.rbc-overlay { + position: absolute; + z-index: 5; + border: 1px solid #e5e5e5; + background-color: #fff; + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25); + padding: 10px; +} +.rbc-overlay > * + * { + margin-top: 1px; +} + +.rbc-overlay-header { + border-bottom: 1px solid #e5e5e5; + margin: -10px -10px 5px -10px; + padding: 2px 10px; +} + +.rbc-agenda-view { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + overflow: auto; +} +.rbc-agenda-view table.rbc-agenda-table { + width: 100%; + border: 1px solid #ddd; + border-spacing: 0; + border-collapse: collapse; +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr > td { + padding: 5px 10px; + vertical-align: top; +} +.rbc-agenda-view table.rbc-agenda-table .rbc-agenda-time-cell { + padding-left: 15px; + padding-right: 15px; + text-transform: lowercase; +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { + border-left: 1px solid #ddd; +} +.rbc-rtl .rbc-agenda-view table.rbc-agenda-table tbody > tr > td + td { + border-left-width: 0; + border-right: 1px solid #ddd; +} +.rbc-agenda-view table.rbc-agenda-table tbody > tr + tr { + border-top: 1px solid #ddd; +} +.rbc-agenda-view table.rbc-agenda-table thead > tr > th { + padding: 3px 5px; + text-align: left; + border-bottom: 1px solid #ddd; +} +.rbc-rtl .rbc-agenda-view table.rbc-agenda-table thead > tr > th { + text-align: right; +} + +.rbc-agenda-time-cell { + text-transform: lowercase; +} +.rbc-agenda-time-cell .rbc-continues-after:after { + content: " »"; +} +.rbc-agenda-time-cell .rbc-continues-prior:before { + content: "« "; +} + +.rbc-agenda-date-cell, +.rbc-agenda-time-cell { + white-space: nowrap; +} + +.rbc-agenda-event-cell { + width: 100%; +} + +.rbc-time-column { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + min-height: 100%; +} +.rbc-time-column .rbc-timeslot-group { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.rbc-timeslot-group { + border-bottom: 1px solid #ddd; + min-height: 40px; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-flow: column nowrap; + flex-flow: column nowrap; +} + +.rbc-time-gutter, +.rbc-header-gutter { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; +} + +.rbc-label { + padding: 0 5px; +} + +.rbc-day-slot { + position: relative; +} +.rbc-day-slot .rbc-events-container { + bottom: 0; + left: 0; + position: absolute; + right: 0; + margin-right: 10px; + top: 0; +} +.rbc-day-slot .rbc-events-container.rbc-rtl { + left: 10px; + right: 0; +} +.rbc-day-slot .rbc-event, .rbc-day-slot .rbc-background-event { + border: 1px solid #265985; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + max-height: 100%; + min-height: 20px; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-flow: column wrap; + flex-flow: column wrap; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + overflow: hidden; + position: absolute; +} +.rbc-day-slot .rbc-background-event { + opacity: 0.75; +} +.rbc-day-slot .rbc-event-label { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; + padding-right: 5px; + width: auto; +} +.rbc-day-slot .rbc-event-content { + width: 100%; + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + word-wrap: break-word; + line-height: 1; + height: 100%; + min-height: 1em; +} +.rbc-day-slot .rbc-time-slot { + border-top: 1px solid #f7f7f7; +} + +.rbc-time-view-resources .rbc-time-gutter, +.rbc-time-view-resources .rbc-time-header-gutter { + position: sticky; + left: 0; + background-color: white; + border-right: 1px solid #ddd; + z-index: 10; + margin-right: -1px; +} +.rbc-time-view-resources .rbc-time-header { + overflow: hidden; +} +.rbc-time-view-resources .rbc-time-header-content { + min-width: auto; + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; + -ms-flex-preferred-size: 0px; + flex-basis: 0px; +} +.rbc-time-view-resources .rbc-time-header-cell-single-day { + display: none; +} +.rbc-time-view-resources .rbc-day-slot { + min-width: 140px; +} +.rbc-time-view-resources .rbc-header, +.rbc-time-view-resources .rbc-day-bg { + width: 140px; + -webkit-box-flex: 1; + -ms-flex: 1 1 0px; + flex: 1 1 0; + -ms-flex-preferred-size: 0 px; + flex-basis: 0 px; +} + +.rbc-time-header-content + .rbc-time-header-content { + margin-left: -1px; +} + +.rbc-time-slot { + -webkit-box-flex: 1; + -ms-flex: 1 0 0px; + flex: 1 0 0; +} +.rbc-time-slot.rbc-now { + font-weight: bold; +} + +.rbc-day-header { + text-align: center; +} + +.rbc-slot-selection { + z-index: 10; + position: absolute; + background-color: rgba(0, 0, 0, 0.5); + color: white; + font-size: 75%; + width: 100%; + padding: 3px; +} + +.rbc-slot-selecting { + cursor: move; +} + +.rbc-time-view { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + width: 100%; + border: 1px solid #ddd; + min-height: 0; +} +.rbc-time-view .rbc-time-gutter { + white-space: nowrap; + text-align: right; +} +.rbc-time-view .rbc-allday-cell { + -webkit-box-sizing: content-box; + box-sizing: content-box; + width: 100%; + height: 100%; + position: relative; +} +.rbc-time-view .rbc-allday-cell + .rbc-allday-cell { + border-left: 1px solid #ddd; +} +.rbc-time-view .rbc-allday-events { + position: relative; + z-index: 4; +} +.rbc-time-view .rbc-row { + -webkit-box-sizing: border-box; + box-sizing: border-box; + min-height: 20px; +} + +.rbc-time-header { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 0; + -ms-flex: 0 0 auto; + flex: 0 0 auto; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} +.rbc-time-header.rbc-overflowing { + border-right: 1px solid #ddd; +} +.rbc-rtl .rbc-time-header.rbc-overflowing { + border-right-width: 0; + border-left: 1px solid #ddd; +} +.rbc-time-header > .rbc-row:first-child { + border-bottom: 1px solid #ddd; +} +.rbc-time-header > .rbc-row.rbc-row-resource { + border-bottom: 1px solid #ddd; +} + +.rbc-time-header-cell-single-day { + display: none; +} + +.rbc-time-header-content { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + min-width: 0; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + border-left: 1px solid #ddd; +} +.rbc-rtl .rbc-time-header-content { + border-left-width: 0; + border-right: 1px solid #ddd; +} +.rbc-time-header-content > .rbc-row.rbc-row-resource { + border-bottom: 1px solid #ddd; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.rbc-time-content { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -ms-flex: 1 0 0%; + flex: 1 0 0%; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + width: 100%; + border-top: 2px solid #ddd; + overflow-y: auto; + position: relative; +} +.rbc-time-content > .rbc-time-gutter { + -webkit-box-flex: 0; + -ms-flex: none; + flex: none; +} +.rbc-time-content > * + * > * { + border-left: 1px solid #ddd; +} +.rbc-rtl .rbc-time-content > * + * > * { + border-left-width: 0; + border-right: 1px solid #ddd; +} +.rbc-time-content > .rbc-day-slot { + width: 100%; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-select: none; +} + +.rbc-current-time-indicator { + position: absolute; + z-index: 3; + left: 0; + right: 0; + height: 1px; + background-color: #74ad31; + pointer-events: none; +} + +.rbc-resource-grouping.rbc-time-header-content { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; +} +.rbc-resource-grouping .rbc-row .rbc-header { + width: 141px; +} + +/*# sourceMappingURL=react-big-calendar.css.map */.rg-shifts { + color: #111827; + font-size: 13px; +} + +.rg-shifts__calendar { + min-height: 760px; +} + +.rg-shifts .rbc-calendar { + min-height: 760px; +} + +.rg-shifts .rbc-toolbar { + gap: 12px; + margin-bottom: 18px; +} + +.rg-shifts .rbc-toolbar button { + border: 1px solid #2563eb; + border-radius: 4px; + background: #2563eb; + color: #ffffff; + padding: 6px 12px; +} + +.rg-shifts .rbc-toolbar button:hover, +.rg-shifts .rbc-toolbar button:focus { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.rg-shifts .rbc-toolbar button.rbc-active { + border-color: #1e40af; + background: #1e40af; + color: #ffffff; +} + +.rg-shifts .rbc-month-view, +.rg-shifts .rbc-time-view { + overflow: hidden; + border: 1px solid #d1d5db; + border-radius: 6px; +} + +.rg-shifts .rbc-header { + padding: 8px 0; + font-weight: 600; +} + +.rg-shifts .rbc-today { + background: #eff6ff; +} + +.rg-shifts .rbc-off-range-bg { + background: #f8fafc; +} + +.rg-shifts .rbc-event { + box-shadow: none; +} +.rg-omnibar { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 640px; + color: #111827; + font-size: 13px; +} + +.rg-omnibar__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.rg-omnibar__title { + font-size: 18px; + font-weight: 600; +} + +.rg-omnibar__count { + color: #6b7280; + font-size: 12px; +} + +.rg-omnibar__input { + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + outline: none; +} + +.rg-omnibar__input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.rg-omnibar__results { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rg-omnibar__item { + display: flex; + width: 100%; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid #dbe2ea; + border-radius: 6px; + background: #ffffff; + text-align: left; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.rg-omnibar__item:hover, +.rg-omnibar__item--active { + border-color: #2563eb; + background: #eff6ff; +} + +.rg-omnibar__item-label { + font-size: 14px; + font-weight: 600; +} + +.rg-omnibar__item-description, +.rg-omnibar__empty { + color: #6b7280; +} +.mapboxgl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgb(0 0 0/0)}.mapboxgl-canvas{left:0;position:absolute;top:0}.mapboxgl-map:-webkit-full-screen{height:100%;width:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom,.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-left,.mapboxgl-ctrl-right,.mapboxgl-ctrl-top,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.mapboxgl-ctrl-top-left{left:0;top:0}.mapboxgl-ctrl-top{left:50%;top:0;transform:translateX(-50%)}.mapboxgl-ctrl-top-right{right:0;top:0}.mapboxgl-ctrl-right{right:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl-bottom{bottom:0;left:50%;transform:translateX(-50%)}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-left{left:0;top:50%;transform:translateY(-50%)}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.mapboxgl-ctrl-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-bottom .mapboxgl-ctrl{float:left;margin:10px 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.mapboxgl-ctrl-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px #0000001a}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{background-color:initial;border:0;box-sizing:border-box;cursor:pointer;display:block;height:32px;outline:none;overflow:hidden;padding:0;width:32px}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:initial}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:#eee}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-up .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 11.7071C4.68342 12.0976 5.31658 12.0976 5.70711 11.7071L9 8.41421L12.2929 11.7071C12.6834 12.0976 13.3166 12.0976 13.7071 11.7071C14.0976 11.3166 14.0976 10.6834 13.7071 10.2929L9.70711 6.29289C9.31658 5.90237 8.68342 5.90237 8.29289 6.29289L4.29289 10.2929C3.90237 10.6834 3.90237 11.3166 4.29289 11.7071Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-arrow-down .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M4.29289 6.29289C4.68342 5.90237 5.31658 5.90237 5.70711 6.29289L9 9.58579L12.2929 6.29289C12.6834 5.90237 13.3166 5.90237 13.7071 6.29289C14.0976 6.68342 14.0976 7.31658 13.7071 7.70711L9.70711 11.7071C9.31658 12.0976 8.68342 12.0976 8.29289 11.7071L4.29289 7.70711C3.90237 7.31658 3.90237 6.68342 4.29289 6.29289Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23333' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E");background-size:18px 18px}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23fff' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-indoor-toggle .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg fill='%23000' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M4.0017 3.0017L4.0017 15.0017L10.0017 15.0017V12.0017H12.0017V15.0017H14.0017L14.0017 3.0017C14.0097 2.86829 13.9894 2.73469 13.9419 2.60973C13.8945 2.48477 13.8211 2.37129 13.7266 2.27678C13.6321 2.18228 13.5186 2.10889 13.3937 2.06147C13.2687 2.01405 13.1351 1.99368 13.0017 2.0017L5.0017 2.0017C4.86829 1.99368 4.73469 2.01405 4.60973 2.06147C4.48477 2.10889 4.37129 2.18228 4.27678 2.27678C4.18228 2.37129 4.10889 2.48477 4.06147 2.60973C4.01405 2.73469 3.99368 2.86829 4.0017 3.0017ZM8.0017 14.0017H6.0017V12.0017H8.0017V14.0017ZM8.0017 10.0017H6.0017L6.0017 8.0017H8.0017V10.0017ZM8.0017 6.0017L6.0017 6.0017V4.0017H8.0017V6.0017ZM12.0017 10.0017H10.0017V8.0017H12.0017V10.0017ZM12.0017 6.0017H10.0017V4.0017L12.0017 4.0017V6.0017Z' fill='%23333333'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:initial;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:#ffffff80;margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{background-color:#fff;border-radius:12px;box-sizing:initial;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{background-color:#ffffff80;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:#0000000d}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0;top:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0;top:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}.mapboxgl-ctrl-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:#000000bf;text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:#ffffffbf;border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.mapboxgl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{border:10px solid #0000;height:0;width:0;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.mapboxgl-popup-close-button{background-color:initial;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.mapboxgl-popup-close-button:hover{background-color:#eee}.mapboxgl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px #0000001a;padding:10px 10px 15px;pointer-events:auto;position:relative}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{left:0;opacity:1;position:absolute;top:0;transition:opacity .2s;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.mapboxgl-user-location-dot:before{animation:mapboxgl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.mapboxgl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px #00000059;box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{height:0;width:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-bottom:7.5px solid #4aa1eb;content:"";position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid #0000;transform:translateY(-28px) skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid #0000;transform:translate(7.5px,-28px) skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{align-items:center;background:#000000b3;color:#fff;display:flex;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;height:100%;justify-content:center;left:0;opacity:0;pointer-events:none;position:absolute;text-align:center;top:0;transition:opacity .75s ease-in-out;transition-delay:1s;width:100%}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button{font-size:16px;font-weight:700;text-align:center}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected{background-color:#ccc;color:#000}.mapboxgl-ctrl button.mapboxgl-ctrl-level-button-selected:hover{background-color:#ccc}/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js index d3df2a6d..ed3669e7 100644 --- a/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js +++ b/Web/Resgrid.Web/wwwroot/js/ng/react-elements.js @@ -1,2 +1,2 @@ -import "./chunks/elements-BnCXm89z.js"; +import "./chunks/elements-DNx2V9CT.min.js"; //# sourceMappingURL=react-elements.js.map diff --git a/Workers/Resgrid.Workers.Console/Commands/TtsStaticPromptRefreshCommand.cs b/Workers/Resgrid.Workers.Console/Commands/TtsStaticPromptRefreshCommand.cs new file mode 100644 index 00000000..62e18460 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/TtsStaticPromptRefreshCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public class TtsStaticPromptRefreshCommand : IQuidjiboCommand + { + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + + public TtsStaticPromptRefreshCommand(int id) + { + Id = id; + } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index 415792ee..eb0afb3f 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -369,6 +369,17 @@ await Client.ScheduleAsync("Communication Test", Cron.MinuteIntervals(15), stoppingToken); + if (!string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl) && !string.IsNullOrWhiteSpace(TtsConfig.StaticPromptAdminKey)) + { + var refreshInterval = TtsConfig.StaticPromptRefreshIntervalMinutes > 0 ? TtsConfig.StaticPromptRefreshIntervalMinutes : 60; + + _logger.Log(LogLevel.Information, "Scheduling TTS Static Prompt Refresh"); + await Client.ScheduleAsync("TTS Static Prompt Refresh", + new Commands.TtsStaticPromptRefreshCommand(18), + Cron.MinuteIntervals(refreshInterval), + stoppingToken); + } + _logger.Log(LogLevel.Information, "Scheduling Weather Alert Import"); await Client.ScheduleAsync("Weather Alert Import", new Commands.WeatherAlertImportCommand(20), diff --git a/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs new file mode 100644 index 00000000..9ec71a7d --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/TtsStaticPromptRefreshTask.cs @@ -0,0 +1,59 @@ +using Autofac; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Workers.Console.Tasks +{ + public class TtsStaticPromptRefreshTask : IQuidjiboHandler + { + public string Name => "TTS Static Prompt Refresh"; + public int Priority => 1; + private readonly ILogger _logger; + + public TtsStaticPromptRefreshTask(ILogger logger) + { + _logger = logger; + } + + public async Task ProcessAsync(TtsStaticPromptRefreshCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + try + { + progress.Report(1, $"Starting the {Name} Task"); + + if (string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl) || string.IsNullOrWhiteSpace(TtsConfig.StaticPromptAdminKey)) + { + _logger.LogInformation("TtsStaticPromptRefresh::Skipping because the TTS service URL or admin key is not configured"); + progress.Report(100, $"Skipping the {Name} Task"); + return; + } + + var ttsAudioService = Bootstrapper.GetKernel().Resolve(); + + _logger.LogInformation("TtsStaticPromptRefresh::Refreshing static prompts"); + await ttsAudioService.RegenerateStaticPromptsAsync(TwilioVoicePromptCatalog.GetStaticPrompts(), cancellationToken); + + progress.Report(100, $"Finishing the {Name} Task"); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Resgrid.Framework.Logging.LogException(ex); + _logger.LogError(ex, "TtsStaticPromptRefresh::Failed to refresh static prompts"); + throw; + } + } + } +}