diff --git a/src/dymaptic.GeoBlazor.Core/Components/Symbols/Symbol.cs b/src/dymaptic.GeoBlazor.Core/Components/Symbols/Symbol.cs index e10f18ff..803633ff 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Symbols/Symbol.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Symbols/Symbol.cs @@ -176,19 +176,21 @@ public SymbolSerializationRecord(string Type, [ProtoMember(27)] public int? YScale { get; init; } - public Symbol FromSerializationRecord() + public Symbol FromSerializationRecord(bool isOutline = false) { return Type switch { "outline" => new Outline(Color, Width, LineStyle is null ? null : Enum.Parse(LineStyle!, true)), - "simple-marker" => new SimpleMarkerSymbol(Outline?.FromSerializationRecord() as Outline, Color, Size, + "simple-marker" => new SimpleMarkerSymbol(Outline?.FromSerializationRecord(true) as Outline, Color, Size, Style is null ? null : Enum.Parse(Style!, true), Angle, XOffset, YOffset), - "simple-line" => new SimpleLineSymbol(Color, Width, LineStyle is null ? null : Enum.Parse(LineStyle!, true)), - "simple-fill" => new SimpleFillSymbol(Outline?.FromSerializationRecord() as Outline, Color, + "simple-line" => isOutline + ? new Outline(Color, Width, LineStyle is null ? null : Enum.Parse(LineStyle!, true)) + : new SimpleLineSymbol(Color, Width, LineStyle is null ? null : Enum.Parse(LineStyle!, true)), + "simple-fill" => new SimpleFillSymbol(Outline?.FromSerializationRecord(true) as Outline, Color, Style is null ? null : Enum.Parse(Style!, true)), "picture-marker" => new PictureMarkerSymbol(Url!, Width, Height, Angle, XOffset, YOffset), "picture-fill" => new PictureFillSymbol(Url!, Width, Height, XOffset, YOffset, XScale, YScale, - Outline?.FromSerializationRecord() as Outline), + Outline?.FromSerializationRecord(true) as Outline), "text" => new TextSymbol(Text ?? string.Empty, Color, HaloColor, HaloSize, MapFont?.FromSerializationRecord(), Angle, BackgroundColor, BorderLineColor, BorderLineSize, HorizontalAlignment is null ? null : Enum.Parse(HorizontalAlignment!, true), diff --git a/src/dymaptic.GeoBlazor.Core/JsModuleManager.cs b/src/dymaptic.GeoBlazor.Core/JsModuleManager.cs index fefb5665..cbc511ea 100644 --- a/src/dymaptic.GeoBlazor.Core/JsModuleManager.cs +++ b/src/dymaptic.GeoBlazor.Core/JsModuleManager.cs @@ -1,5 +1,6 @@ +using dymaptic.GeoBlazor.Core.Objects; using Microsoft.JSInterop; -using System.Security.Claims; +using System.Globalization; namespace dymaptic.GeoBlazor.Core; @@ -8,6 +9,11 @@ namespace dymaptic.GeoBlazor.Core; /// public static class JsModuleManager { + /// + /// The browser's culture information. Used to deserialize numbers and dates in . + /// + public static CultureInfo ClientCultureInfo { get; set; } = CultureInfo.CurrentCulture; + /// /// Retrieves the main entry point for the GeoBlazor Core JavaScript module. /// @@ -15,14 +21,23 @@ public static async Task GetArcGisJsCore(IJSRuntime jsRuntim { Version? version = System.Reflection.Assembly.GetAssembly(typeof(JsModuleManager))!.GetName().Version; + IJSObjectReference core; + if (proModule is null) { - return await jsRuntime + core = await jsRuntime .InvokeAsync("import", cancellationToken, $"./_content/dymaptic.GeoBlazor.Core/js/arcGisJsInterop.js?v={version}"); } + else + { + core = await proModule.InvokeAsync("getCore", cancellationToken); + } + + string browserLanguage = await core.InvokeAsync("getBrowserLanguage", cancellationToken); + ClientCultureInfo = new CultureInfo(browserLanguage); - return await proModule.InvokeAsync("getCore", cancellationToken); + return core; } /// diff --git a/src/dymaptic.GeoBlazor.Core/Objects/AttributesDictionary.cs b/src/dymaptic.GeoBlazor.Core/Objects/AttributesDictionary.cs index 075f6a23..1297a693 100644 --- a/src/dymaptic.GeoBlazor.Core/Objects/AttributesDictionary.cs +++ b/src/dymaptic.GeoBlazor.Core/Objects/AttributesDictionary.cs @@ -1,5 +1,6 @@ using dymaptic.GeoBlazor.Core.Components; using ProtoBuf; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -13,7 +14,7 @@ namespace dymaptic.GeoBlazor.Core.Objects; public class AttributesDictionary : IEquatable { /// - /// Constructor + /// Constructor for a new, empty dictionary /// public AttributesDictionary() { @@ -30,6 +31,7 @@ public AttributesDictionary(Dictionary dictionary) { _backingDictionary = new Dictionary(); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + CultureInfo cultureInfo = JsModuleManager.ClientCultureInfo; foreach (KeyValuePair kvp in dictionary) { @@ -41,19 +43,23 @@ public AttributesDictionary(Dictionary dictionary) JsonValueKind.Array => jsonElement.Deserialize(typeof(IEnumerable), options), JsonValueKind.False => false, JsonValueKind.True => true, - JsonValueKind.Number => jsonElement.ToString().Contains('.') - ? Convert.ChangeType(jsonElement.ToString(), TypeCode.Double) - : Convert.ChangeType(jsonElement.ToString(), TypeCode.Int64), + JsonValueKind.Number => double.Parse(jsonElement.ToString(), cultureInfo), JsonValueKind.String => jsonElement.ToString(), _ => jsonElement }; - if (typedValue is string stringValue && Guid.TryParse(stringValue, out Guid guidValue)) + if (typedValue is string stringValue) { - typedValue = guidValue; + if (Guid.TryParse(stringValue, out Guid guidValue)) + { + typedValue = guidValue; + } + else if (DateTime.TryParse(stringValue, cultureInfo, DateTimeStyles.None, out DateTime dateValue)) + { + typedValue = dateValue; + } } _backingDictionary[kvp.Key] = (typedValue ?? default(object?))!; } - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract else if (kvp.Value is null) // could be null from serialization { @@ -66,24 +72,39 @@ public AttributesDictionary(Dictionary dictionary) } } + /// + /// Internal constructor for use with Protobuf deserialization + /// + /// + /// The serialized attributes to use. + /// internal AttributesDictionary(AttributeSerializationRecord[]? serializedAttributes) { _backingDictionary = new Dictionary(); if (serializedAttributes is not null) { + CultureInfo cultureInfo = JsModuleManager.ClientCultureInfo; + foreach (AttributeSerializationRecord record in serializedAttributes) { switch (record.ValueType) { + case "System.Int32": + _backingDictionary[record.Key] = int.Parse(record.Value!, cultureInfo); + + break; + case "System.Double": case "[object Number]": - _backingDictionary[record.Key] = double.Parse(record.Value!); + _backingDictionary[record.Key] = double.Parse(record.Value!, cultureInfo); break; + case "System.Boolean": case "[object Boolean]": _backingDictionary[record.Key] = bool.Parse(record.Value!); break; + case "System.String": case "[object String]": if (Guid.TryParse(record.Value, out Guid guidValue)) { @@ -95,8 +116,9 @@ internal AttributesDictionary(AttributeSerializationRecord[]? serializedAttribut } break; + case "System.DateTime": case "[object Date]": - _backingDictionary[record.Key] = DateTime.Parse(record.Value!); + _backingDictionary[record.Key] = DateTime.Parse(record.Value!, cultureInfo); break; default: diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts index 05d0fa6a..4b319db8 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts @@ -385,7 +385,7 @@ export async function buildMapView(id: string, dotNetReference: any, long: numbe } if (hasValue(popupEnabled)) { - view.popupEnabled = popupEnabled; + view.popupEnabled = popupEnabled as boolean; } if (hasValue(constraints)) { @@ -3339,4 +3339,8 @@ export function getWebMapBookmarks(viewId: string) { export function setStretchTypeForRenderer(rendererId, stretchType) { let renderer = arcGisObjectRefs[rendererId] as RasterStretchRenderer; renderer.stretchType = stretchType; +} + +export function getBrowserLanguage(): string { + return navigator.language; } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/dotNetBuilder.ts b/src/dymaptic.GeoBlazor.Core/Scripts/dotNetBuilder.ts index 16bfbb18..214b48dd 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/dotNetBuilder.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/dotNetBuilder.ts @@ -99,17 +99,14 @@ import Domain from "@arcgis/core/layers/support/Domain"; import CodedValueDomain from "@arcgis/core/layers/support/CodedValueDomain"; import InheritedDomain from "@arcgis/core/layers/support/InheritedDomain"; import RangeDomain from "@arcgis/core/layers/support/RangeDomain"; -import Effect = __esri.Effect; import TileInfo from "@arcgis/core/layers/support/TileInfo"; import LOD from "@arcgis/core/layers/support/LOD"; import LayerSearchSource from "@arcgis/core/widgets/Search/LayerSearchSource"; -import SearchSource from "@arcgis/core/widgets/Search/SearchSource"; import LocatorSearchSource from "@arcgis/core/widgets/Search/LocatorSearchSource"; import SearchResult = __esri.SearchResult; import SuggestResult = __esri.SuggestResult; import FeatureType from "@arcgis/core/layers/support/FeatureType"; import FeatureTemplate from "@arcgis/core/layers/support/FeatureTemplate"; -import FeatureTemplateThumbnail = __esri.FeatureTemplateThumbnail; import LabelClass from "@arcgis/core/layers/support/LabelClass"; import Renderer from "@arcgis/core/renderers/Renderer"; import PieChartRenderer from "@arcgis/core/renderers/PieChartRenderer"; @@ -120,7 +117,6 @@ import PieChartScheme = __esri.PieChartScheme; import SizeScheme = __esri.SizeScheme; import SizeSchemeForPoint = __esri.SizeSchemeForPoint; import SizeSchemeForPolygon = __esri.SizeSchemeForPolygon; -import UniqueValuesResult = __esri.UniqueValuesResult; import AuthoringInfo from "@arcgis/core/renderers/support/AuthoringInfo"; import AddressCandidate = __esri.AddressCandidate; import FeatureSet from "@arcgis/core/rest/support/FeatureSet"; diff --git a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj index df63a176..c9c778a5 100644 --- a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj +++ b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj @@ -33,6 +33,8 @@ + + diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocalizationTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocalizationTests.cs new file mode 100644 index 00000000..833c295c --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocalizationTests.cs @@ -0,0 +1,59 @@ +using dymaptic.GeoBlazor.Core.Objects; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Globalization; +using System.Runtime.Serialization; + + +namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Components; + +public class LocalizationTests: TestRunnerBase +{ + [TestMethod] + public void TestCanDeserializeAmericanDoubleAttribute() + { + string originalCulture = JsModuleManager.ClientCultureInfo.Name; + SetCulture("en-US"); + AttributeSerializationRecord doubleAttribute = new("Value", "1.1", "[object Number]"); + AttributesDictionary attributes = new([doubleAttribute]); + Assert.AreEqual(1.1, attributes["Value"]); + SetCulture(originalCulture); + } + + [TestMethod] + public void TestCanDeserializeEuropeanDoubleAttribute() + { + string originalCulture = JsModuleManager.ClientCultureInfo.Name; + SetCulture("de-DE"); + AttributeSerializationRecord doubleAttribute = new("Value", "1,1", "[object Number]"); + AttributesDictionary attributes = new([doubleAttribute]); + Assert.AreEqual(1.1, attributes["Value"]); + SetCulture(originalCulture); + } + + [TestMethod] + public void TestDeserializeAmericanDoubleAttributeInEuropeanCultureReturnsWrongValue() + { + string originalCulture = JsModuleManager.ClientCultureInfo.Name; + SetCulture("de-DE"); + AttributeSerializationRecord doubleAttribute = new("Value", "1.1", "[object Number]"); + AttributesDictionary attributes = new([doubleAttribute]); + Assert.AreEqual(11.0, attributes["Value"]); + SetCulture(originalCulture); + } + + [TestMethod] + public void TestDeserializeEuropeanDoubleAttributeInAmericanCultureReturnsWrongValue() + { + string originalCulture = JsModuleManager.ClientCultureInfo.Name; + SetCulture("en-US"); + AttributeSerializationRecord doubleAttribute = new("Value", "1,1", "[object Number]"); + AttributesDictionary attributes = new([doubleAttribute]); + Assert.AreEqual(11.0, attributes["Value"]); + SetCulture(originalCulture); + } + + private void SetCulture(string cultureName) + { + JsModuleManager.ClientCultureInfo = new CultureInfo(cultureName); + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor index 93732666..a903b414 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor @@ -177,14 +177,29 @@ try { bool hasParameters = methodInfo.GetParameters().Any(); - if (hasParameters) + if (methodInfo.ReturnType != typeof(Task)) { - void RenderHandler() => _methodsWithRenderedMaps.Add(methodInfo.Name); - await (Task)methodInfo.Invoke(this, [(Action)RenderHandler])!; + if (hasParameters) + { + void RenderHandler() => _methodsWithRenderedMaps.Add(methodInfo.Name); + methodInfo.Invoke(this, [(Action)RenderHandler]); + } + else + { + methodInfo.Invoke(this, null); + } } else { - await (Task)methodInfo.Invoke(this, null)!; + if (hasParameters) + { + void RenderHandler() => _methodsWithRenderedMaps.Add(methodInfo.Name); + await (Task)methodInfo.Invoke(this, [(Action)RenderHandler])!; + } + else + { + await (Task)methodInfo.Invoke(this, null)!; + } } _passed[methodInfo.Name] = _resultBuilder.ToString(); diff --git a/test/dymaptic.GeoBlazor.Core.Test/SerializationUnitTests.cs b/test/dymaptic.GeoBlazor.Core.Test/SerializationUnitTests.cs index 3f5f2699..3efa286f 100644 --- a/test/dymaptic.GeoBlazor.Core.Test/SerializationUnitTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test/SerializationUnitTests.cs @@ -57,7 +57,9 @@ public void RoundTripSerializeGraphicToJson() Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color); Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size); Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title); - + Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count); + Assert.AreEqual(graphic.Attributes["testString"], deserialized.Attributes["testString"]); + Assert.AreEqual(graphic.Attributes["testNumber"], deserialized.Attributes["testNumber"]); } [TestMethod] @@ -78,6 +80,33 @@ public void SerializeToProtobuf() Console.WriteLine($"SerializeGraphicToJson: {sw.ElapsedMilliseconds}ms"); Console.WriteLine($"Size: {data.Length} bytes"); } + + [TestMethod] + public void RoundTripSerializeToProtobuf() + { + var graphic = new Graphic(new Point(_random.NextDouble() * 10 + 11.0, + _random.NextDouble() * 10 + 50.0), + new SimpleMarkerSymbol(new Outline(new MapColor("green")), new MapColor("red"), 10), + new PopupTemplate("Test", "Test Content
{testString}
{testNumber}", new[] { "*" }), + new AttributesDictionary( + new Dictionary { { "testString", "test" }, { "testNumber", 123 } })); + ProtoGraphicCollection collection = new(new[] { graphic.ToSerializationRecord() }); + using MemoryStream ms = new(); + Serializer.Serialize(ms, collection); + byte[] data = ms.ToArray(); + ProtoGraphicCollection deserializedCollection = + Serializer.Deserialize((ReadOnlyMemory)data); + Graphic deserialized = deserializedCollection.Graphics[0].FromSerializationRecord(); + Assert.AreEqual(((Point)graphic.Geometry!).Latitude, ((Point)deserialized.Geometry!).Latitude); + Assert.AreEqual(((Point)graphic.Geometry!).Longitude, ((Point)deserialized.Geometry!).Longitude); + Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Color); + Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Outline!.Color, ((SimpleMarkerSymbol)deserialized.Symbol!).Outline!.Color); + Assert.AreEqual(((SimpleMarkerSymbol)graphic.Symbol!).Size, ((SimpleMarkerSymbol)deserialized.Symbol!).Size); + Assert.AreEqual(graphic.PopupTemplate!.Title, deserialized.PopupTemplate!.Title); + Assert.AreEqual(graphic.Attributes.Count, deserialized.Attributes.Count); + Assert.AreEqual(graphic.Attributes["testString"], deserialized.Attributes["testString"]); + Assert.AreEqual(graphic.Attributes["testNumber"], deserialized.Attributes["testNumber"]); + } private readonly Random _random = new(); } \ No newline at end of file