Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Maps] Add Windows map handler based on Webview #604

Merged
merged 24 commits into from
May 31, 2023

Conversation

rmarinho
Copy link
Contributor

@rmarinho rmarinho commented Sep 5, 2022

Description of Change

This is the initial work to support the Map feature on Windows, since there's no official MapControl yet on WinUI we provide this webview based implementation for now.

Fixes:
#605

PR Checklist

Additional information

@brminnick
Copy link
Collaborator

Thanks Rui! Could you first please submit a New Feature Proposal.

Once you've submitted a Proposal, we will vote to approve adding the new feature to the .NET MAUI Community Toolkit.

Here's some more information on the process:

@brminnick
Copy link
Collaborator

Closing this PR until Proposal has been approved

@brminnick brminnick closed this Sep 5, 2022
@bijington bijington reopened this Oct 6, 2022
@bijington
Copy link
Contributor

The proposal has been approved 🥳

@VladislavAntonyuk VladislavAntonyuk added the needs discussion Discuss it on the next Monthly standup label Oct 31, 2022
@brminnick brminnick added the hacktoberfest-accepted A PR that has been approved during Hacktoberfest label Nov 1, 2022
@VladislavAntonyuk VladislavAntonyuk removed the needs discussion Discuss it on the next Monthly standup label Nov 7, 2022
@ghost ghost added stale The author has not responded in over 30 days help wanted This proposal has been approved and is ready to be implemented labels Dec 10, 2022
@JORGEGO

This comment was marked as off-topic.

@mattleibow mattleibow mentioned this pull request Jan 16, 2023
1 task
@VladislavAntonyuk VladislavAntonyuk added the needs discussion Discuss it on the next Monthly standup label Feb 27, 2023
@VladislavAntonyuk VladislavAntonyuk marked this pull request as ready for review March 7, 2023 20:08
@VladislavAntonyuk VladislavAntonyuk removed help wanted This proposal has been approved and is ready to be implemented stale The author has not responded in over 30 days needs discussion Discuss it on the next Monthly standup labels Mar 7, 2023
@ghost ghost added stale The author has not responded in over 30 days help wanted This proposal has been approved and is ready to be implemented labels May 5, 2023
Copy link
Member

@jfversluis jfversluis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of things to get this all done, great work Vlad, thank you!

@brminnick brminnick removed the stale The author has not responded in over 30 days label May 25, 2023
Copy link
Member

@jfversluis jfversluis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! Looks good to me!

@jfversluis jfversluis merged commit 61a64de into CommunityToolkit:main May 31, 2023
@Inrego
Copy link

Inrego commented Sep 13, 2024

I'm not sure if this is the appropriate place to ask. But could someone help out with a proof of concept on a custom handler to use custom icons for pins on Windows? There are similar guides for iOS and Android out there, but nothing for this Windows implementation.

@symbiogenesis
Copy link

symbiogenesis commented Sep 13, 2024

I built one that expects SVG Path data (which is the same as MAUI Path data)

I consume it with an MVVM approach, as explained here: https://dev.to/symbiogenesis/use-net-maui-map-control-with-mvvm-dfl

If you build on top of the GitHub repo linked at the bottom of my blog post, it might be easiest. Because everything is wired up already.

Overwrite the Windows map handler in that repo with the following code. I didn't put in any work to make it a fully general library, so you will just have to work with the build errors.

It has a bunch of features you may not need, like pin selection, pin cloning, and pin dragging.

It is expecting a type called Record with a Location that is a lat/long and an ID which is a NULID, and a geometry field containing a string of SVG path data, along with a pin color, and fill rule (another standard SVG path attribute that exists in MAUI paths too).

You could rewrite it to use bitmaps instead, if you like. But simple single-path SVG files are nice because vectors.

// <copyright file="CustomMapHandler.cs" company="Edward Miller">
// The MIT License (MIT)
// Copyright (c) Edward Miller
// </copyright>

/* -----------------------------------------------------------------------
Original CommunityToolkit.Maui code, from which this was adapted, was under the the following license:

The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All Rights Reserved

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 * ----------------------------------------------------------------------- */

namespace MyProject.Platforms.Windows.Handlers;

using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text.Json;
using MyProject;
using MyProject.Converters;
using MyProject.CustomControls;
using MyProject.CustomControls.Interfaces;
using MyProject.Data.Enums;
using MyProject.Data.Models;
using MyProject.Extensions;
using MyProject.Messages;
using MyProject.Messages.MessageArgs;
using CommunityToolkit.Mvvm.Messaging;
using global::Windows.Devices.Geolocation;
using Microsoft.Maui.Maps;
using Microsoft.Maui.Maps.Handlers;
using Microsoft.Maui.Platform;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using NUlid;
using IMap = Microsoft.Maui.Maps.IMap;

[SupportedOSPlatform("windows10.0.19041.0")]
internal sealed class CustomMapHandler : MapHandler, IRecipient<RecordTypeChanged>
{
    public static readonly PropertyMapper<ICustomMap, IMapHandler> CustomMapper = new(Mapper)
    {
        [nameof(ICustomMap.MapState)] = MapMapState,
        [nameof(ICustomMap.MapType)] = MapMapType,
        [nameof(ICustomMap.IsShowingUser)] = MapIsShowingUser,
        [nameof(ICustomMap.IsScrollEnabled)] = MapIsScrollEnabled,
        [nameof(ICustomMap.IsTrafficEnabled)] = MapIsTrafficEnabled,
        [nameof(ICustomMap.IsZoomEnabled)] = MapIsZoomEnabled,
        [nameof(ICustomMap.Pins)] = MapPins,
        [nameof(ICustomMap.Elements)] = MapElements,
        [nameof(ICustomMap.SelectedItem)] = MapSelectedItem,
    };

    public static readonly CommandMapper<ICustomMap, IMapHandler> CustomCommandMapper = new(CommandMapper)
    {
        [nameof(ICustomMap.MoveToRegion)] = MapMoveToRegion,
    };

    private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

    private readonly IMessenger messenger;
    private readonly MauiWebView mauiWebView;

    private MapSpan? regionToGo;
    private bool isWebViewLoaded;

    public CustomMapHandler(IMessenger messenger, MauiWebView mauiWebView)
        : base(CustomMapper, CustomCommandMapper)
    {
        this.messenger = messenger;
        this.mauiWebView = mauiWebView;

        if (string.IsNullOrWhiteSpace(MapsKey))
        {
            throw new InvalidOperationException("You need to specify a Bing Maps Key");
        }

        var symbolSize = RecordToPathConverter.CalculatedSize / 2;
        var mapPage = GetMapHtmlPage(MapsKey, !EnableStreetView, EnableMapSymbols, symbolSize);
        this.mauiWebView.LoadHtml(mapPage, null);

        this.messenger.Register(this);
    }

    ~CustomMapHandler()
    {
        this.messenger.UnregisterAll(this);
    }

    public string? LastPinsJson { get; set; }

    internal static string? MapsKey { get; set; }

    internal static bool EnableStreetView { get; set; }

    internal static bool EnableMapSymbols { get; set; } = true;

    public static void MapMapState(IMapHandler handler, IMap map)
    {
        if (map is not CustomMap customMap)
        {
            return;
        }

        var isDragging = customMap.MapState is MapState.Moving or MapState.Cloning;

        if (!isDragging)
        {
            return;
        }

        if (customMap.SelectedItem is not Record selectedRecord)
        {
            return;
        }

        var isCloning = customMap.MapState is MapState.Cloning;

        CallJSMethod(handler, $"beginDragPin('{selectedRecord.IdString}', {isCloning.ToStringLower()});");
    }

    public static new void MapMapType(IMapHandler handler, IMap map) => CallJSMethod(handler, $"setMapType('{map.MapType}');");

    public static new void MapIsZoomEnabled(IMapHandler handler, IMap map) => CallJSMethod(handler, $"toggleMapZoom({map.IsZoomEnabled.ToStringLower()});");

    public static new void MapIsScrollEnabled(IMapHandler handler, IMap map) => CallJSMethod(handler, $"togglePanning({map.IsScrollEnabled.ToStringLower()});");

    public static new void MapIsTrafficEnabled(IMapHandler handler, IMap map) => CallJSMethod(handler, $"toggleTraffic({map.IsTrafficEnabled.ToStringLower()});");

    public static new async void MapIsShowingUser(IMapHandler handler, IMap map)
    {
        if (map.IsShowingUser)
        {
            var location = await GetCurrentLocation();
            if (location != null)
            {
                CallJSMethod(handler, $"setLocationPin({location.Latitude},{location.Longitude});");
            }
        }
        else
        {
            CallJSMethod(handler, "removeLocationPin();");
        }
    }

    public static new void MapPins(IMapHandler handler, IMap map)
    {
        if (map is not CustomMap customMap || !customMap.IsLoaded)
        {
            return;
        }

        var customPins = customMap.ItemsSource.Cast<Record>().Select(GeneratePinDTO).Where(p => p.Location != null).ToList();

        var pinsJson = JsonSerializer.Serialize(customPins, JsonOptions);

        if (handler is CustomMapHandler customMapHandler && pinsJson != customMapHandler.LastPinsJson)
        {
            customMapHandler.LastPinsJson = pinsJson;
            CallJSMethod(handler, $"addPins({pinsJson});");
        }
    }

    public static void MapSelectedItem(IMapHandler handler, ICustomMap map)
    {
        if (handler is not CustomMapHandler customMapHandler)
        {
            return;
        }

        if (map.SelectedViaJavascript)
        {
            map.SelectedViaJavascript = false;
            return;
        }

        if (map.SelectedItem is not Record selectedRecord || selectedRecord.Location is null)
        {
            CallJSMethod(customMapHandler, $"unsetSelectedPin();");
            return;
        }

        var selectedLocation = LocationExtensions.GetLocation(selectedRecord.Location);

        handler.VirtualView.Clicked(selectedLocation);
    }

    public static new void MapElements(IMapHandler handler, IMap map) => Debug.WriteLine("Update for all Map Elements requested.");

    public static new void UpdateMapElement(IMapElement element) => Debug.WriteLine($"Update for Map Element requested for ID: {element.MapElementId}");

    public static new void MapMoveToRegion(IMapHandler handler, IMap map, object? arg)
    {
        if (arg is not MapSpan newRegion || handler is not CustomMapHandler mapHandler)
        {
            return;
        }

        mapHandler.regionToGo = newRegion;

        var (southWest, northEast) = newRegion.ToBounds();

        CallJSMethod(handler, $"setRegion({southWest.Latitude}, {southWest.Longitude}, {northEast.Latitude}, {northEast.Longitude});");
    }

    public void Receive(RecordTypeChanged message)
    {
        var recordTypeChangedJson = JsonSerializer.Serialize(message.Value, JsonOptions);

        CallJSMethod(this, $"changeRecordType({recordTypeChangedJson});");
    }

    /// <inheritdoc/>
    protected override FrameworkElement CreatePlatformView() => this.mauiWebView;

    protected override void ConnectHandler(FrameworkElement platformView)
    {
        base.ConnectHandler(platformView);

        if (platformView is MauiWebView mauiWebView)
        {
            mauiWebView.NavigationCompleted += this.WebViewNavigationCompleted;
            mauiWebView.WebMessageReceived += this.WebViewWebMessageReceived;
        }
    }

    protected override void DisconnectHandler(FrameworkElement platformView)
    {
        if (platformView is MauiWebView mauiWebView)
        {
            mauiWebView.NavigationCompleted -= this.WebViewNavigationCompleted;
            mauiWebView.WebMessageReceived -= this.WebViewWebMessageReceived;
        }

        base.DisconnectHandler(platformView);
    }

    private static PinArgs GeneratePinDTO(Record record) => new(record);

    private static void CallJSMethod(IMapHandler handler, string script)
    {
        if (handler.PlatformView is WebView2 webView2 && webView2.CoreWebView2 != null)
        {
            _ = webView2.DispatcherQueue.TryEnqueue(async () => await webView2.ExecuteScriptAsync(script));
        }
    }

    private static string GetMapHtmlPage(string key, bool disableStreetView, bool usingCustomPins, double symbolSize) =>
        $$$"""
        <!DOCTYPE html>
        <html>
        <head>
            <meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline' https://*.bing.com https://*.virtualearth.net; style-src 'self' 'unsafe-inline' https://*.bing.com https://*.virtualearth.net; media-src *">
            <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
            <style>
                body, html{
                    padding:0;
                    margin:0;
                }
            </style>
        </head>
        <body onload='loadMap();'>
            <div id="myMap"></div>
            <script src="https://www.bing.com/api/maps/mapcontrol?key={{{key}}}"></script>
            <script defer>
                var map;
                var anchor;
                var mainLayer;
                var dragLayer;
                var isCloning;
                var selectedPin;
                var locationPin;
                var originalRecords;
                const symbolSize = {{{symbolSize}}};
                const usingCustomPins = {{{usingCustomPins.ToStringLower()}}};
                const svgCache = new Map();

                function loadMap() {
                    anchor = new Microsoft.Maps.Point(symbolSize / 2, symbolSize / 2);
                    map = new Microsoft.Maps.Map(document.getElementById('myMap'), {
                        disableBirdseye : true,
                    	disableZooming: true,
                    //	disablePanning: true,
                        disableStreetside: {{{disableStreetView.ToStringLower()}}},
                        showScalebar: false,
                        showLocateMeButton: false,
                        showDashboard: false,
                        showTermsLink: false,
                        showTrafficButton: false
                    });
                    Microsoft.Maps.Events.addHandler(map, 'viewrendered', invokeBoundsArgs);
                    Microsoft.Maps.Events.addHandler(map, "dblclick", handleDblClick);
                }

                function handleDblClick(e) {
                    var clickArgs = {
                        messageType: '{{{nameof(ClickArgs)}}}',
                        location: e.location,
                        identifier: 'unknown',
                        invokeClickEvent: true
                    };
                    invokeHandlerAction(clickArgs);
                }

                function toggleMapZoom(enable) {
                    map.setOptions({
                        disableZooming: !enable,
                    });
                }

                function togglePanning(enable) {
                    map.setOptions({
                        disablePanning: !enable,
                    });
                }

                function toggleTraffic(enable) {
                    map.setOptions({
                        disableTraffic: !enable,
                    });
                }

                function setMapType(mauiMapType) {
                    let mapTypeID;

                    switch(mauiMapType) {
                        case 'Street':
                        mapTypeID = Microsoft.Maps.MapTypeId.road;
                        break;
                        case 'Satellite':
                        mapTypeID = Microsoft.Maps.MapTypeId.aerial;
                        break;
                        case 'Hybrid':
                        mapTypeID = Microsoft.Maps.MapTypeId.aerial;
                        break;
                        default:
                        mapTypeID = Microsoft.Maps.MapTypeId.road;
                    }
                    map.setView({
                        mapTypeId: mapTypeID
                    });
                }

                function setRegion(southWestLat, southWestLong, northEastLat, northEastLong) {
                    const southWest = new Microsoft.Maps.Location(southWestLat, southWestLong);
                    const northEast = new Microsoft.Maps.Location(northEastLat, northEastLong);
                    const locations = [southWest, northEast];
                    const rect = Microsoft.Maps.LocationRect.fromLocations(locations);
                    map.setView({ bounds: rect, padding: 80 });
                }

                function beginDragPin(id, isCloningTemp) {
                    isCloning = isCloningTemp;

                    if (!originalRecords || !Array.isArray(originalRecords) || originalRecords.length < 1) {
                        alert("Records not found");
                        endDragMode();
                        return;
                    }

                    const record = originalRecords.find(r => r.identifier == id);

                    if (!record) {
                        alert("cannot find original record for selected pin " + id);
                        endDragMode();
                        return;
                    }

                    const pinLocation = new Microsoft.Maps.Location(record.location.latitude, record.location.longitude);

                    const originalPinToDrag = mainLayer.getPrimitives().find(a => a.identifier == id);

                    if (!originalPinToDrag) {
                        alert("Couldn't find pin to drag");
                        endDragMode();
                        return;
                    }

                    let draggablePin;

                    if (usingCustomPins) {
                        const customIcon = createCustomSvgIcon(record.geometry, "yellow", record.fillRule);
                        draggablePin = new Microsoft.Maps.Pushpin(originalPinToDrag.getLocation(), { icon: customIcon, draggable: true, anchor: anchor });
                    }
                    else {
                        draggablePin = new Microsoft.Maps.Pushpin(originalPinToDrag.getLocation(), { color: 'yellow', draggable: true, anchor: anchor });
                    }

                    Microsoft.Maps.Events.addHandler(draggablePin, 'dragend', function(e) { endDragPin(e, id); });

                    if (!dragLayer) {
                        dragLayer = new Microsoft.Maps.Layer();
                        dragLayer.add(draggablePin);
                        map.layers.insert(dragLayer);
                    }
                    else {
                        dragLayer.clear();
                        dragLayer.add(draggablePin);
                    }

                    mainLayer.setVisible(false);
                    dragLayer.setVisible(true);
                }

                function endDragPin(e, id) {
                    const pin = e.target;

                    if (pin) {
                        let message = isCloning ? "Clone new pin at this location" : "Save new location for pin?";

                        if (!confirm(message)) {
                            endDragMode();
                            return;
                        }

                        if (isCloning)
                        {
                            invokeCloneArgs(e.location, id);
                            isCloning = false;
                        }
                        else
                        {
                            selectedPin.setLocation(e.location);
                            invokeClickArgs(e.location, id);
                        }
                    }

                    endDragMode();
                }

                function endDragMode() {
                    dragLayer.setVisible(false);
                    mainLayer.setVisible(true);
                }

                function changeRecordType(recordToUpdate) {

                    const originalRecord = originalRecords.find(r => r.identifier == recordToUpdate.identifier);

                    let pinToUpdate;

                    const isSelected = selectedPin.identifier == recordToUpdate.identifier;

                    console.log('isSelected: ' + isSelected);

                    if (isSelected) {
                        pinToUpdate = selectedPin;
                    }
                    else {
                        pinToUpdate = mainLayer.getPrimitives().find(a => a.identifier == recordToUpdate.identifier);
                    }

                    originalRecord.geometry = recordToUpdate.geometry;
                    originalRecord.fillRule = recordToUpdate.fillRule;

                    const customIcon = createCustomSvgIcon(originalRecord.geometry, "yellow", originalRecord.fillRule);
                    pinToUpdate.setOptions({ icon: customIcon, anchor: anchor });
                }

                function addPins(records) {
                    originalRecords = records;

                    const isNew = !mainLayer;

                    if (isNew) {
                        mainLayer = new Microsoft.Maps.Layer();
                    }
                    else {
                        mainLayer.clear();
                    }

                    records.forEach(record => {
                        const newPin = generateNewPin(record);

                        if (!newPin) {
                            return;
                        }

                        newPin.originalColor = record.pinColor;
                        newPin.identifier = record.identifier;

                        mainLayer.add(newPin);
                    });

                    if (isNew) {
                        map.layers.insert(mainLayer);
                    }

                    Microsoft.Maps.Events.addHandler(mainLayer, 'click', handleClick);
                    Microsoft.Maps.Events.addHandler(mainLayer, 'rightclick', handleClick);
                }

                function generateNewPin(record) {
                    const location = new Microsoft.Maps.Location(record.location.latitude, record.location.longitude);

                    if (usingCustomPins) {
                        const customIcon = createCustomSvgIcon(record.geometry, record.pinColor, record.fillRule);
                        return new Microsoft.Maps.Pushpin(location, { icon: customIcon, anchor: anchor });
                    }
                    else {
                        return new Microsoft.Maps.Pushpin(location, { color: record.pinColor });
                    }
                }

                function handleClick(e) {
                    const newSelectedPin = e.primitive;

                    if (!(newSelectedPin instanceof Microsoft.Maps.Pushpin)) {
                        return; // shape is not a pushPin
                    }

                    if (selectedPin === newSelectedPin) {
                        console.log("pin was already selected");
                        return;
                    }

                    invokeClickArgs(newSelectedPin.getLocation(), newSelectedPin.identifier);

                    if (usingCustomPins) {
                        updateCustomPinColor(selectedPin, 0);
                        updateCustomPinColor(newSelectedPin, Number.MAX_SAFE_INTEGER, "yellow");
                    } else {
                        if (selectedPin) {
                            selectedPin.setOptions({ color: selectedPin.originalColor, zIndex: 0 });
                        }

                        newSelectedPin.setOptions({ color: 'yellow', zIndex: Number.MAX_SAFE_INTEGER });
                    }

                    selectedPin = newSelectedPin;
                }

                function updateCustomPinColor(pin, zIndex, newPinColor = null) {
                    if (pin) {
                        const record = originalRecords.find(r => r.identifier == pin.identifier);
                        if (record) {
                            const pinColor = newPinColor == null ? record.pinColor : newPinColor;
                            const customIcon = createCustomSvgIcon(record.geometry, pinColor, record.fillRule);
                            pin.setOptions({ icon: customIcon, anchor: anchor, zIndex: zIndex });
                        }
                    }
                }

                function unsetSelectedPin() {
                    if (selectedPin) {
                        selectedPin.setOptions({ color: selectedPin.originalColor, zIndex: 0 });
                        selectedPin = null;
                    }
                }

                function invokeBoundsArgs() {
                    const bounds = map.getBounds();
                    bounds.messageType = '{{{nameof(Bounds)}}}';

                    invokeHandlerAction(bounds);
                }

                function invokeClickArgs(location, identifier) {
                    const clickArgs = {
                        messageType: '{{{nameof(ClickArgs)}}}',
                        identifier: identifier,
                        location: location,
                    };

                    invokeHandlerAction(clickArgs);
                }

                function invokeCloneArgs(location, identifier) {
                    const cloneArgs = {
                        messageType: '{{{nameof(CloneArgs)}}}',
                        recordIdToClone: identifier,
                        cloneLocation: location
                    };

                    invokeHandlerAction(cloneArgs);
                }

                function setLocationPin(latitude, longitude) {
                    if(!locationPin)
                    {
                        let location = new Microsoft.Maps.Location(latitude, longitude);
                        locationPin = new Microsoft.Maps.Pushpin(location, { title: 'Current Location' });
                    }

                    map.entities.push(locationPin);
                }

                function removeLocationPin() {
                    if(locationPin != null)
                    {
                        map.entities.remove(locationPin);
                        locationPin = null;
                    }
                }

                function createCustomSvgIcon(pathData, fillColor, fillRule) {
                  // Validate pathData
                  if (typeof pathData !== 'string' || pathData.length === 0 || !/^m|M/.test(pathData)) {
                    throw new Error('Invalid pathData. Ensure the record contains all the geometry information from the symbol');
                  }

                  // Generate a unique cache key based on the input parameters
                  const cacheKey = `${pathData}-${fillColor}-${fillRule}-${symbolSize}`;

                  // Check if the result is already in the cache and return it if available
                  if (svgCache.has(cacheKey)) {
                    return svgCache.get(cacheKey);
                  }

                  // Create an offscreen SVG and path elements
                  const tempSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
                  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
                  path.setAttribute("d", pathData);
                  tempSvg.appendChild(path);

                  // Set the offscreen SVG element's style to avoid affecting the layout
                  tempSvg.style.position = "absolute";
                  tempSvg.style.visibility = "hidden";
                  tempSvg.style.width = "0";
                  tempSvg.style.height = "0";

                  // Add the offscreen SVG element to the DOM to calculate the path's bounding box
                  document.body.appendChild(tempSvg);

                  // Get the bounding box
                  const { x, y, width, height } = path.getBBox();

                  // Remove the offscreen SVG element from the DOM
                  document.body.removeChild(tempSvg);

                  // Calculate the scaling and translation required to fit the path within the viewBox
                  const scale = 1000 / Math.max(width, height);
                  const translateX = (1000 - width * scale) / 2 - x * scale;
                  const translateY = (1000 - height * scale) / 2 - y * scale;

                  // Create the final SVG with the appropriate scaling and translation applied to the path
                  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet" width="${symbolSize}" height="${symbolSize}"><path d="${pathData}" fill-rule="${fillRule}" fill="${fillColor}" transform="translate(${translateX},${translateY}) scale(${scale})"/></svg>`;

                  // Store the generated SVG string in the cache and return it
                  svgCache.set(cacheKey, svg);

                  return svg;
                }

                function invokeHandlerAction(data) {
                    window.chrome.webview.postMessage(data);
                }
            </script>
        </body>
        </html>
        """;

    private static async ValueTask<Location?> GetCurrentLocation()
    {
        var locationWhenInUse = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();

        if (locationWhenInUse != PermissionStatus.Granted)
        {
            return Constants.DefaultLocation;
        }

        var geolocator = new Geolocator() { DesiredAccuracy = PositionAccuracy.High };
        var position = await geolocator.GetGeopositionAsync();
        return new Location(position.Coordinate.Latitude, position.Coordinate.Longitude);
    }

    private static void ProcessNonClickEvent(CustomMap customMap, ClickArgs clickArgs)
    {
        if (customMap.MapState is MapState.Moving or MapState.Cloning)
        {
            customMap.MovePin(clickArgs.Identifier, clickArgs.Location);
            return;
        }

        customMap.SelectedViaJavascript = true;
        UpdateSelectedItem(customMap, clickArgs.Identifier);
    }

    private static void UpdateSelectedItem(CustomMap customMap, string identifier)
    {
        var recordId = Ulid.Parse(identifier);
        var newSelectedItem = customMap.ItemsSource.Cast<Record>().FirstOrDefault(i => i.Id == recordId);

        customMap.SelectedItem = newSelectedItem != customMap.SelectedItem ? newSelectedItem : null;
    }

    private static bool ProcessWebEvent<T>(CoreWebView2WebMessageReceivedEventArgs args, Action<T> action)
    {
        try
        {
            var result = JsonSerializer.Deserialize<T>(args.WebMessageAsJson, JsonOptions);
            if (result is not null)
            {
                action.Invoke(result);
                return true;
            }
        }
        catch (JsonException)
        {
            // If not the right event, then skip
        }

        return false;
    }

    private void WebViewNavigationCompleted(WebView2 sender, CoreWebView2NavigationCompletedEventArgs args)
    {
        if (this.isWebViewLoaded || !args.IsSuccess)
        {
            return;
        }

        this.isWebViewLoaded = true;

        // Update initial properties when our page is loaded
        CustomMapper.UpdateProperties(this, this.VirtualView);

        if (this.regionToGo is not null)
        {
            MapMoveToRegion(this, this.VirtualView, this.regionToGo);
        }
    }

    private void WebViewWebMessageReceived(WebView2 sender, CoreWebView2WebMessageReceivedEventArgs args)
    {
        if (args.WebMessageAsJson is "undefined" || string.IsNullOrWhiteSpace(args.WebMessageAsJson))
        {
            return;
        }

        if (ProcessWebEvent<Bounds>(args, this.UpdateVisibleRegion))
        {
            return;
        }

        if (ProcessWebEvent<ClickArgs>(args, this.ProcessClickArgs))
        {
            return;
        }

        if (ProcessWebEvent<CloneArgs>(args, this.ProcessCloneArgs))
        {
            return;
        }

        throw new InvalidDataException($"Cannot parse the following message from WebView: {args.WebMessageAsJson}");
    }

    private void UpdateVisibleRegion(Bounds mapRect)
    {
        if (mapRect.Center is null)
        {
            return;
        }

        var location = new Location(mapRect.Center.Latitude, mapRect.Center.Longitude);
        this.VirtualView.VisibleRegion = new MapSpan(location, mapRect.Height, mapRect.Width);
    }

    private void ProcessClickArgs(ClickArgs clickArgs)
    {
        var customMap = (CustomMap)this.VirtualView;

        if (clickArgs.InvokeClickEvent)
        {
            this.VirtualView.Clicked(clickArgs.Location);
        }
        else
        {
            ProcessNonClickEvent(customMap, clickArgs);
        }
    }

    private void ProcessCloneArgs(CloneArgs cloneArgs)
    {
        var customMap = (CustomMap)this.VirtualView;

        if (customMap.MapState is MapState.Cloning)
        {
            customMap.ClonePin(cloneArgs.RecordIdToClone, cloneArgs.CloneLocation);
        }
    }

    private sealed class CloneArgs
    {
        public string MessageType => nameof(CloneArgs);

        public required string RecordIdToClone { get; set; }

        public required Location CloneLocation { get; set; }
    }

    private sealed class ClickArgs
    {
        public string MessageType => nameof(ClickArgs);

        public required string Identifier { get; set; }

        public required Location Location { get; set; }

        public bool InvokeClickEvent { get; set; }
    }

    private sealed class Bounds
    {
        public string MessageType => nameof(Bounds);

        public required Location Center { get; set; }

        public required double Width { get; set; }

        public required double Height { get; set; }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
hacktoberfest-accepted A PR that has been approved during Hacktoberfest help wanted This proposal has been approved and is ready to be implemented
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants