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$': '/__mocks__/svg.ts', + '\\.png$': '/__mocks__/png.ts', '^@/(.*)$': '/src/$1', '^@test/(.*)$': '/test/$1', }, modulePathIgnorePatterns: ['/dist/'], setupFilesAfterEnv: ['/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: () => , + }, + }, + args: { + // + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => { + const [markerLocation, setMarkerLocation] = useState( + args.markerLocation, + ); + + const mapClicked = (location: Location) => { + setMarkerLocation(location); + console.log(`Map clicked at [${location.latitude},${location.longitude}]`); + }; + + return ( + + ); +}; + +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: + '© OpenStreetMap contributors', + }, + ], +}; +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 © Kartverket', + }, + ], +}; +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 © Kartverket', + }, + ], +}; +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: 'Dummy link', + }, + ], + }); + + 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 = {}) => { + 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(); +}; 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 © Kartverket', + }, + { + url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=norgeskart_bakgrunn2&zoom={z}&x={x}&y={y}', + attribution: 'Data © Kartverket', + }, +]; + +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 ( + + {layers.map((layer, i) => ( + + ))} + + + + ); +}; + +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 ? ( + + ) : 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"