From 25587a0cc70dee341ca91eb6fc76ace8fd4958fe Mon Sep 17 00:00:00 2001
From: Jostein Tveit <jostein.tveit@dsb.no>
Date: Mon, 8 Aug 2022 07:24:55 +0200
Subject: [PATCH] Map component

---
 .gitignore                         |   3 +
 .storybook/main.js                 |   5 +
 __mocks__/png.ts                   |   2 +
 custom.d.ts                        |   5 +
 jest.config.js                     |  10 ++
 package.json                       |   4 +
 rollup.config.js                   |   4 +
 src/components/Map/Map.module.css  |   5 +
 src/components/Map/Map.stories.tsx | 155 +++++++++++++++++++++++++++++
 src/components/Map/Map.test.tsx    | 140 ++++++++++++++++++++++++++
 src/components/Map/Map.tsx         | 128 ++++++++++++++++++++++++
 src/components/Map/index.ts        |   2 +
 src/components/index.ts            |   2 +
 yarn.lock                          |  72 ++++++++++++++
 14 files changed, 537 insertions(+)
 create mode 100644 __mocks__/png.ts
 create mode 100644 src/components/Map/Map.module.css
 create mode 100644 src/components/Map/Map.stories.tsx
 create mode 100644 src/components/Map/Map.test.tsx
 create mode 100644 src/components/Map/Map.tsx
 create mode 100644 src/components/Map/index.ts

diff --git a/.gitignore b/.gitignore
index 568629f4..cdc69d84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -380,3 +380,6 @@ MigrationBackup/
 
 # Ionide (cross platform F# VS Code tools) working folder
 .ionide/
+
+# JetBrains IntelliJ IDEA
+.idea/
diff --git a/.storybook/main.js b/.storybook/main.js
index 8c09caaa..0a6e9fca 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -88,6 +88,11 @@ module.exports = {
       ],
     });
 
+    config.module.rules.push({
+      test: /\.png$/i,
+      type: 'asset/resource'
+    });
+
     config.resolve.alias = {
       ...config.resolve.alias,
       '@': AppSourceDir,
diff --git a/__mocks__/png.ts b/__mocks__/png.ts
new file mode 100644
index 00000000..ab004a74
--- /dev/null
+++ b/__mocks__/png.ts
@@ -0,0 +1,2 @@
+export default 'PngURL';
+export const ReactComponent = 'div';
diff --git a/custom.d.ts b/custom.d.ts
index edd9b718..2129b9f4 100644
--- a/custom.d.ts
+++ b/custom.d.ts
@@ -11,3 +11,8 @@ declare module '*.css' {
   const styles: { [className: string]: string };
   export default styles;
 }
+
+declare module '*.png' {
+  const value: any;
+  export = value;
+}
diff --git a/jest.config.js b/jest.config.js
index f435b385..0a0b8cc2 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -6,10 +6,20 @@ module.exports = {
   moduleNameMapper: {
     '.(css|less|scss)$': 'identity-obj-proxy',
     '\\.svg$': '<rootDir>/__mocks__/svg.ts',
+    '\\.png$': '<rootDir>/__mocks__/png.ts',
     '^@/(.*)$': '<rootDir>/src/$1',
     '^@test/(.*)$': '<rootDir>/test/$1',
   },
   modulePathIgnorePatterns: ['<rootDir>/dist/'],
   setupFilesAfterEnv: ['<rootDir>/test/jest.setup.ts'],
   testPathIgnorePatterns: ['/node_modules/', '/scripts/templates/'],
+  globals: {
+    'ts-jest': {
+      tsconfig: {
+        allowJs: true,
+      },
+    },
+  },
+  transform: { '\\.js$': ['ts-jest'] },
+  transformIgnorePatterns: ['node_modules/(?!react-leaflet)/'],
 };
diff --git a/package.json b/package.json
index d36d4043..cb35b9cb 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
   "dependencies": {
     "@altinn/figma-design-tokens": "^0.3.0",
     "@react-hookz/web": "^15.0.1",
+    "leaflet": "^1.8.0",
+    "react-leaflet": "^4.0.1",
     "react-number-format": "^4.9.3"
   },
   "resolutions": {
@@ -37,6 +39,7 @@
     "@babel/core": "^7.18.10",
     "@mdx-js/react": "^1.6.22",
     "@rollup/plugin-commonjs": "^22.0.1",
+    "@rollup/plugin-image": "^2.1.1",
     "@rollup/plugin-json": "^4.1.0",
     "@rollup/plugin-node-resolve": "^13.3.0",
     "@rollup/plugin-typescript": "^8.3.3",
@@ -65,6 +68,7 @@
     "@testing-library/react": "^13.3.0",
     "@testing-library/user-event": "^14.3.0",
     "@types/jest": "^28.1.6",
+    "@types/leaflet": "^1.7.11",
     "@types/node": "^17.0.45",
     "@types/react": "^18.0.15",
     "@types/testing-library__jest-dom": "^5.14.5",
diff --git a/rollup.config.js b/rollup.config.js
index f1d65832..b4cda4ca 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -2,6 +2,7 @@ import resolve from '@rollup/plugin-node-resolve';
 import commonjs from '@rollup/plugin-commonjs';
 import typescript from '@rollup/plugin-typescript';
 import json from '@rollup/plugin-json';
+import image from '@rollup/plugin-image';
 import dts from 'rollup-plugin-dts';
 import postcss from 'rollup-plugin-postcss';
 import peerDepsExternal from 'rollup-plugin-peer-deps-external';
@@ -30,6 +31,8 @@ export default [
       altinnFigmaTokensExceptCss,
       /@react-hookz\/web/,
       /react-number-format/,
+      /react-leaflet/,
+      /leaflet/,
     ],
     plugins: [
       peerDepsExternal(),
@@ -40,6 +43,7 @@ export default [
       svgr({ exportType: 'named' }),
       postcss(),
       terser(),
+      image(),
     ],
   },
   {
diff --git a/src/components/Map/Map.module.css b/src/components/Map/Map.module.css
new file mode 100644
index 00000000..64481d8b
--- /dev/null
+++ b/src/components/Map/Map.module.css
@@ -0,0 +1,5 @@
+.map {
+  position: relative;
+  height: 50rem;
+  width: 100%;
+}
diff --git a/src/components/Map/Map.stories.tsx b/src/components/Map/Map.stories.tsx
new file mode 100644
index 00000000..2fdde345
--- /dev/null
+++ b/src/components/Map/Map.stories.tsx
@@ -0,0 +1,155 @@
+import React, { useState } from 'react';
+import type { ComponentStory, ComponentMeta } from '@storybook/react';
+import { config } from 'storybook-addon-designs';
+
+import { StoryPage } from '@sb/StoryPage';
+
+import type { Location } from './Map';
+import { Map } from './Map';
+
+const figmaLink = '';
+
+export default {
+  title: `Components/Map`,
+  component: Map,
+  parameters: {
+    layout: 'fullscreen',
+    design: config([
+      {
+        type: 'figma',
+        url: figmaLink,
+      },
+      {
+        type: 'link',
+        url: figmaLink,
+      },
+    ]),
+    docs: {
+      page: () => <StoryPage description={`Map component`} />,
+    },
+  },
+  args: {
+    //
+  },
+} as ComponentMeta<typeof Map>;
+
+const Template: ComponentStory<typeof Map> = (args) => {
+  const [markerLocation, setMarkerLocation] = useState<Location | undefined>(
+    args.markerLocation,
+  );
+
+  const mapClicked = (location: Location) => {
+    setMarkerLocation(location);
+    console.log(`Map clicked at [${location.latitude},${location.longitude}]`);
+  };
+
+  return (
+    <Map
+      {...args}
+      markerLocation={markerLocation}
+      onClick={mapClicked}
+    />
+  );
+};
+
+export const Default = Template.bind({});
+Default.args = {};
+Default.parameters = {
+  docs: {
+    description: {
+      story:
+        'This is the default map you get if you do not specify any map layers. Kartverket with layers "europa_forenklet" and "norgeskart_bakgrunn2"',
+    },
+  },
+};
+
+export const MapWithMarkerZoomAndCenter = Template.bind({});
+MapWithMarkerZoomAndCenter.args = {
+  markerLocation: {
+    latitude: 59.2641592,
+    longitude: 10.4036248,
+  },
+  zoom: 16,
+  centerLocation: {
+    latitude: 59.2641592,
+    longitude: 10.4036248,
+  },
+};
+MapWithMarkerZoomAndCenter.parameters = {
+  docs: {
+    description: {
+      story: 'Default map with marker location and center location set',
+    },
+  },
+};
+
+export const OpenStreetMap = Template.bind({});
+OpenStreetMap.args = {
+  layers: [
+    {
+      url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+      subdomains: ['a', 'b', 'c'],
+      attribution:
+        '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>',
+    },
+  ],
+};
+OpenStreetMap.parameters = {
+  docs: {
+    description: {
+      story: 'OpenStreetMap',
+    },
+  },
+};
+
+export const KartverketTerrain = Template.bind({});
+KartverketTerrain.args = {
+  layers: [
+    {
+      url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=terreng_norgeskart&zoom={z}&x={x}&y={y}',
+      attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>',
+    },
+  ],
+};
+KartverketTerrain.parameters = {
+  docs: {
+    description: {
+      story: 'Kartverket terrain map',
+    },
+  },
+};
+
+export const KartverketSea = Template.bind({});
+KartverketSea.args = {
+  layers: [
+    {
+      url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}',
+      attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>',
+    },
+  ],
+};
+KartverketSea.parameters = {
+  docs: {
+    description: {
+      story: 'Kartverket sea map',
+    },
+  },
+};
+
+export const GoogleMaps = Template.bind({});
+GoogleMaps.args = {
+  layers: [
+    {
+      url: 'https://{s}.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
+      subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
+      attribution: '© Google Maps',
+    },
+  ],
+};
+GoogleMaps.parameters = {
+  docs: {
+    description: {
+      story: 'Google Maps',
+    },
+  },
+};
diff --git a/src/components/Map/Map.test.tsx b/src/components/Map/Map.test.tsx
new file mode 100644
index 00000000..b958a680
--- /dev/null
+++ b/src/components/Map/Map.test.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { render as renderRtl, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { MapProps, Location } from './Map';
+import { Map } from './Map';
+
+const user = userEvent.setup();
+
+window.scrollTo = jest.fn();
+
+describe('Map', () => {
+  describe('Marker', () => {
+    it('should show marker when marker is set', () => {
+      render({
+        markerLocation: { latitude: 59.2641592, longitude: 10.4036248 },
+      });
+      expect(locationMarker()).toBeInTheDocument();
+    });
+
+    it('should not show marker when marker is not set', () => {
+      render({ markerLocation: undefined });
+      expect(locationMarker()).not.toBeInTheDocument();
+    });
+  });
+
+  describe('Click map', () => {
+    it('should call mapClicked with correct coordinates when map is clicked', async () => {
+      const handleMapClicked = jest.fn();
+      render({
+        onClick: handleMapClicked,
+      });
+
+      await clickMap();
+
+      expect(handleMapClicked).toHaveBeenCalledWith({
+        latitude: 59.265880628258095,
+        longitude: 10.371093750000002,
+      } as Location);
+    });
+
+    it('should get different coordinates when map is clicked at different location', async () => {
+      const mapClicked = jest.fn();
+      render({
+        onClick: mapClicked,
+      });
+
+      // First click
+      await clickMap();
+      expect(mapClicked).toBeCalledTimes(1);
+      const firstLocation = mapClicked.mock.calls[0][0] as Location;
+
+      // Second click at different location
+      await clickMap(50, 50);
+      expect(mapClicked).toBeCalledTimes(2);
+      const secondLocation = mapClicked.mock.calls[1][0] as Location;
+
+      expect(firstLocation.latitude).not.toBe(secondLocation.latitude);
+      expect(firstLocation.longitude).not.toBe(secondLocation.longitude);
+    });
+  });
+
+  it('should display attribution link', () => {
+    render({
+      layers: [
+        {
+          url: 'dummy',
+          attribution: '<a href="https://dummylink.invalid">Dummy link</a>',
+        },
+      ],
+    });
+
+    expect(getLink('Dummy link')).toBeInTheDocument();
+  });
+
+  it('should show map with zoom buttons when readonly is false', () => {
+    render({
+      readOnly: false,
+    });
+
+    expect(getButton('Zoom in')).toBeInTheDocument();
+    expect(getButton('Zoom out')).toBeInTheDocument();
+  });
+
+  it('should show map without zoom buttons when readonly is true', () => {
+    render({
+      readOnly: true,
+    });
+
+    expect(getButton('Zoom in')).not.toBeInTheDocument();
+    expect(getButton('Zoom out')).not.toBeInTheDocument();
+  });
+});
+
+function locationMarker() {
+  return screen.queryByRole('button', { name: 'Marker' });
+}
+
+function getButton(name: string) {
+  return screen.queryByRole('button', { name: name });
+}
+
+function getLink(name: string) {
+  return screen.queryByRole('link', { name: name });
+}
+
+async function clickMap(clientX = 0, clientY = 0) {
+  const firstMapLayer = screen.getAllByRole('presentation')[0];
+  await user.pointer([
+    {
+      pointerName: 'mouse',
+      target: firstMapLayer,
+      coords: {
+        clientX,
+        clientY,
+      },
+      keys: '[MouseLeft]',
+    },
+  ]);
+}
+
+const render = (props: Partial<MapProps> = {}) => {
+  const allProps = {
+    readOnly: false,
+    layers: undefined,
+    centerLocation: {
+      latitude: 59.2641592,
+      longitude: 10.4036248,
+    } as Location,
+    zoom: 4,
+    markerLocation: {
+      latitude: 59.2641592,
+      longitude: 10.4036248,
+    } as Location,
+    onClick: jest.fn(),
+    ...props,
+  };
+
+  return renderRtl(<Map {...allProps} />);
+};
diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx
new file mode 100644
index 00000000..38821f66
--- /dev/null
+++ b/src/components/Map/Map.tsx
@@ -0,0 +1,128 @@
+import UrlIcon from 'leaflet/dist/images/marker-icon.png';
+import RetinaUrlIcon from 'leaflet/dist/images/marker-icon-2x.png';
+import ShadowUrlIcon from 'leaflet/dist/images/marker-shadow.png';
+import { icon } from 'leaflet';
+import {
+  AttributionControl,
+  MapContainer,
+  Marker,
+  TileLayer,
+  useMapEvents,
+} from 'react-leaflet';
+import React from 'react';
+
+import classes from './Map.module.css';
+import 'leaflet/dist/leaflet.css';
+
+// Default is center of Norway
+const DefaultCenterLocation: Location = {
+  latitude: 64.888996,
+  longitude: 12.8186054,
+};
+const DefaultZoom = 4;
+
+// Default map layers from Kartverket
+const DefaultMapLayers: MapLayer[] = [
+  {
+    url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=europa_forenklet&zoom={z}&x={x}&y={y}',
+    attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>',
+  },
+  {
+    url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=norgeskart_bakgrunn2&zoom={z}&x={x}&y={y}',
+    attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>',
+  },
+];
+
+export interface Location {
+  latitude: number;
+  longitude: number;
+}
+
+export interface MapLayer {
+  url: string;
+  attribution?: string;
+  subdomains?: string[];
+}
+
+export interface MapProps {
+  readOnly?: boolean;
+  layers?: MapLayer[];
+  centerLocation?: Location;
+  zoom?: number;
+  markerLocation?: Location;
+  onClick?: (location: Location) => void;
+}
+
+export const Map = ({
+  readOnly = false,
+  layers = DefaultMapLayers,
+  centerLocation = DefaultCenterLocation,
+  zoom = DefaultZoom,
+  markerLocation,
+  onClick,
+}: MapProps) => {
+  return (
+    <MapContainer
+      className={classes.map}
+      center={locationToTuple(centerLocation)}
+      zoom={zoom}
+      zoomControl={!readOnly}
+      dragging={!readOnly}
+      touchZoom={!readOnly}
+      doubleClickZoom={!readOnly}
+      scrollWheelZoom={!readOnly}
+      attributionControl={false}
+    >
+      {layers.map((layer, i) => (
+        <TileLayer
+          key={i}
+          url={layer.url}
+          attribution={layer.attribution}
+          subdomains={layer.subdomains ? layer.subdomains : []}
+          opacity={readOnly ? 0.5 : 1.0}
+        />
+      ))}
+      <AttributionControl prefix={false} />
+      <LocationMarker
+        location={markerLocation}
+        onClick={onClick}
+      />
+    </MapContainer>
+  );
+};
+
+interface LocationMarkerProps {
+  location?: Location;
+  onClick?: (location: Location) => void;
+}
+function LocationMarker({ location, onClick }: LocationMarkerProps) {
+  useMapEvents({
+    click(me) {
+      if (onClick) {
+        onClick({
+          latitude: me.latlng.lat,
+          longitude: me.latlng.lng,
+        });
+      }
+    },
+  });
+
+  const markerIcon = icon({
+    iconUrl: UrlIcon,
+    iconRetinaUrl: RetinaUrlIcon,
+    shadowUrl: ShadowUrlIcon,
+    iconSize: [25, 41],
+    iconAnchor: [12, 41],
+  });
+
+  return location ? (
+    <Marker
+      position={locationToTuple(location)}
+      icon={markerIcon}
+    />
+  ) : null;
+}
+
+function locationToTuple(location: Location): [number, number] {
+  return [location.latitude, location.longitude];
+}
diff --git a/src/components/Map/index.ts b/src/components/Map/index.ts
new file mode 100644
index 00000000..138303ea
--- /dev/null
+++ b/src/components/Map/index.ts
@@ -0,0 +1,2 @@
+export type { MapLayer, Location } from './Map';
+export { Map } from './Map';
diff --git a/src/components/index.ts b/src/components/index.ts
index 2146e209..d3dd9ac5 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -6,3 +6,5 @@ export { Accordion, AccordionHeader, AccordionContent } from './Accordion';
 export { Button, ButtonVariant } from './Button';
 export { List, ListItem, BorderStyle } from './List';
 export { TextField, IconVariant, ReadOnlyVariant } from './TextField';
+export type { Location, MapLayer } from './Map';
+export { Map } from './Map';
diff --git a/yarn.lock b/yarn.lock
index c480e875..c5a7aae8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14,6 +14,7 @@ __metadata:
     "@mdx-js/react": ^1.6.22
     "@react-hookz/web": ^15.0.1
     "@rollup/plugin-commonjs": ^22.0.1
+    "@rollup/plugin-image": ^2.1.1
     "@rollup/plugin-json": ^4.1.0
     "@rollup/plugin-node-resolve": ^13.3.0
     "@rollup/plugin-typescript": ^8.3.3
@@ -42,6 +43,7 @@ __metadata:
     "@testing-library/react": ^13.3.0
     "@testing-library/user-event": ^14.3.0
     "@types/jest": ^28.1.6
+    "@types/leaflet": ^1.7.11
     "@types/node": ^17.0.45
     "@types/react": ^18.0.15
     "@types/testing-library__jest-dom": ^5.14.5
@@ -65,6 +67,7 @@ __metadata:
     identity-obj-proxy: ^3.0.0
     jest: ^28.1.3
     jest-environment-jsdom: ^28.1.3
+    leaflet: ^1.8.0
     lint-staged: ^13.0.3
     node-polyfill-webpack-plugin: ^2.0.1
     pinst: ^3.0.0
@@ -72,6 +75,7 @@ __metadata:
     prettier: ^2.7.1
     react: ^18.2.0
     react-dom: ^18.2.0
+    react-leaflet: ^4.0.1
     react-number-format: ^4.9.3
     rollup: ^2.77.2
     rollup-plugin-dts: ^4.2.2
@@ -3728,6 +3732,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@react-leaflet/core@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "@react-leaflet/core@npm:2.0.0"
+  peerDependencies:
+    leaflet: ^1.8.0
+    react: ^18.0.0
+    react-dom: ^18.0.0
+  checksum: 8528b05bb3f2218dc783d508fad571ed34986bb9a15e8296f3ca6e090934ee4b9858172cab25bc2ae3255c880660d648477bb609c496bee70d1c0e3fb99f0533
+  languageName: node
+  linkType: hard
+
 "@rollup/plugin-commonjs@npm:^22.0.1":
   version: 22.0.1
   resolution: "@rollup/plugin-commonjs@npm:22.0.1"
@@ -3745,6 +3760,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@rollup/plugin-image@npm:^2.1.1":
+  version: 2.1.1
+  resolution: "@rollup/plugin-image@npm:2.1.1"
+  dependencies:
+    "@rollup/pluginutils": ^3.1.0
+    mini-svg-data-uri: ^1.2.3
+  peerDependencies:
+    rollup: ^1.20.0 || ^2.0.0
+  checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
+  languageName: node
+  linkType: hard
+
 "@rollup/plugin-json@npm:^4.1.0":
   version: 4.1.0
   resolution: "@rollup/plugin-json@npm:4.1.0"
@@ -5538,6 +5565,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/geojson@npm:*":
+  version: 7946.0.10
+  resolution: "@types/geojson@npm:7946.0.10"
+  checksum: 12c407c2dc93ecb26c08af533ee732f1506a9b29456616ba7ba1d525df96206c28ddf44a528f6a5415d7d22893e9d967420940a9c095ee5e539c1eba5fefc1f4
+  languageName: node
+  linkType: hard
+
 "@types/glob@npm:*, @types/glob@npm:^7.1.1":
   version: 7.2.0
   resolution: "@types/glob@npm:7.2.0"
@@ -5680,6 +5714,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/leaflet@npm:^1.7.11":
+  version: 1.7.11
+  resolution: "@types/leaflet@npm:1.7.11"
+  dependencies:
+    "@types/geojson": "*"
+  checksum: 6613dbc91f174545ed39183a8564a344152f1884f02ab4dbc002b6bd4816d8525b5247e62217694763daf727ca5e589f40144dc809edb7194064e6bdc2f4369a
+  languageName: node
+  linkType: hard
+
 "@types/lodash@npm:^4.14.167":
   version: 4.14.182
   resolution: "@types/lodash@npm:4.14.182"
@@ -14079,6 +14122,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"leaflet@npm:^1.8.0":
+  version: 1.8.0
+  resolution: "leaflet@npm:1.8.0"
+  checksum: 4a27895d4253844f2f13f94cbc52a2030b10833cdf553085d931b834745236a8d2a38844a01eb968cc109eb43268bfadb97aad85b51deffb17b6ccd106542e00
+  languageName: node
+  linkType: hard
+
 "leven@npm:^3.1.0":
   version: 3.1.0
   resolution: "leven@npm:3.1.0"
@@ -14848,6 +14898,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mini-svg-data-uri@npm:^1.2.3":
+  version: 1.4.4
+  resolution: "mini-svg-data-uri@npm:1.4.4"
+  bin:
+    mini-svg-data-uri: cli.js
+  checksum: 997f1fbd8d59a70f03761e18626d335197a3479cb9d1ff75678e4b64b864d32a0b8fc18115eabde035e5299b8b4a354a78e57dd6ac10f9d604162a6170898d09
+  languageName: node
+  linkType: hard
+
 "minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1":
   version: 1.0.1
   resolution: "minimalistic-assert@npm:1.0.1"
@@ -17221,6 +17280,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-leaflet@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "react-leaflet@npm:4.0.1"
+  dependencies:
+    "@react-leaflet/core": ^2.0.0
+  peerDependencies:
+    leaflet: ^1.8.0
+    react: ^18.0.0
+    react-dom: ^18.0.0
+  checksum: f5e94c0e3b49f8d7d3c464185f8624ab0ad6958a950dd153974183ed93be1b61216c1cddd09967a85e65762f6696a39dfac086ab770980689405e80b3c43a6c1
+  languageName: node
+  linkType: hard
+
 "react-merge-refs@npm:^1.0.0":
   version: 1.1.0
   resolution: "react-merge-refs@npm:1.1.0"