@local-logic/maps 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env.example +3 -0
  2. package/.storybook/defaults.ts +40 -0
  3. package/.storybook/globals.css +32 -0
  4. package/.storybook/main.ts +19 -0
  5. package/.storybook/preview.tsx +28 -0
  6. package/CHANGELOG.md +7 -0
  7. package/README.md +3 -0
  8. package/eslint.config.mjs +46 -0
  9. package/lint-staged.config.cjs +4 -0
  10. package/package.json +78 -0
  11. package/postcss.config.cjs +6 -0
  12. package/prettier.config.cjs +1 -0
  13. package/src/components/Map/Root/BaseMap/Empty/index.tsx +7 -0
  14. package/src/components/Map/Root/BaseMap/Google/index.tsx +96 -0
  15. package/src/components/Map/Root/BaseMap/Mapbox/index.tsx +66 -0
  16. package/src/components/Map/Root/BaseMap/Maptiler/index.tsx +59 -0
  17. package/src/components/Map/Root/BaseMap/index.tsx +36 -0
  18. package/src/components/Map/Root/BaseMap/styles.ts +2 -0
  19. package/src/components/Map/Root/BaseMap/types.ts +3 -0
  20. package/src/components/Map/Root/Markers/Cluster/index.tsx +26 -0
  21. package/src/components/Map/Root/Markers/Cluster/styles.ts +14 -0
  22. package/src/components/Map/Root/Markers/Google/index.tsx +49 -0
  23. package/src/components/Map/Root/Markers/Google/styles.ts +1 -0
  24. package/src/components/Map/Root/Markers/Mapbox/index.tsx +41 -0
  25. package/src/components/Map/Root/Markers/Maptiler/index.tsx +44 -0
  26. package/src/components/Map/Root/Markers/index.tsx +61 -0
  27. package/src/components/Map/Root/Markers/styles.ts +29 -0
  28. package/src/components/Map/Root/Markers/types.ts +15 -0
  29. package/src/components/Map/Root/constants.ts +6 -0
  30. package/src/components/Map/Root/context.tsx +15 -0
  31. package/src/components/Map/Root/index.tsx +17 -0
  32. package/src/components/Map/Root/types.ts +42 -0
  33. package/src/components/Map/index.stories.tsx +117 -0
  34. package/src/components/Map/index.test.tsx +16 -0
  35. package/src/components/Map/index.tsx +1 -0
  36. package/src/index.ts +1 -0
  37. package/tailwind.config.cjs +1 -0
  38. package/tsconfig.json +14 -0
  39. package/tsconfig.node.json +8 -0
  40. package/vite-env.d.ts +13 -0
  41. package/vite.config.ts +46 -0
package/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ VITE_MAPTILER_KEY=
2
+ VITE_MAPBOX_KEY=
3
+ VITE_GOOGLE_KEY=
@@ -0,0 +1,40 @@
1
+ export const mapDefaults = {
2
+ mapProvider: {
3
+ table: { disable: true },
4
+ },
5
+ center: {
6
+ table: { disable: true },
7
+ },
8
+ markers: {
9
+ table: { disable: true },
10
+ },
11
+ zoom: {
12
+ name: "Zoom",
13
+ control: {
14
+ type: "number",
15
+ min: 0,
16
+ max: 50,
17
+ },
18
+ },
19
+ zoomPosition: {
20
+ name: "Zoom position",
21
+ control: { type: "radio" },
22
+ options: ["top-left", "top-right", "bottom-left", "bottom-right"],
23
+ },
24
+ pitch: {
25
+ name: "Pitch",
26
+ control: {
27
+ type: "number",
28
+ min: 0,
29
+ max: 50,
30
+ },
31
+ },
32
+ bearing: {
33
+ name: "Bearing",
34
+ control: {
35
+ type: "number",
36
+ min: 0,
37
+ max: 50,
38
+ },
39
+ },
40
+ };
@@ -0,0 +1,32 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ html {
6
+ height: 100%;
7
+ }
8
+
9
+ body {
10
+ font-family: 'Inter', sans-serif;
11
+ }
12
+
13
+ h1,
14
+ h2,
15
+ h3,
16
+ h4,
17
+ h5,
18
+ h6 {
19
+ font-family: 'Inter', sans-serif;
20
+ }
21
+
22
+ @layer utilities {
23
+ /* Chrome, Safari and Opera */
24
+ .no-scrollbar::-webkit-scrollbar {
25
+ display: none;
26
+ }
27
+
28
+ .no-scrollbar {
29
+ -ms-overflow-style: none; /* IE and Edge */
30
+ scrollbar-width: none; /* Firefox */
31
+ }
32
+ }
@@ -0,0 +1,19 @@
1
+ import type { StorybookConfig } from "@storybook/react-vite";
2
+
3
+ const storybookCfg: StorybookConfig = {
4
+ stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
5
+ addons: [
6
+ "@storybook/addon-links",
7
+ "@storybook/addon-essentials",
8
+ "@storybook/addon-interactions",
9
+ ],
10
+ core: {
11
+ builder: "@storybook/builder-vite",
12
+ },
13
+ framework: "@storybook/react-vite",
14
+ typescript: {
15
+ reactDocgen: false,
16
+ },
17
+ };
18
+
19
+ export default storybookCfg;
@@ -0,0 +1,28 @@
1
+ import "@local-logic/core/style.css";
2
+ import "./globals.css";
3
+ import { Preview } from '@storybook/react';
4
+ import { withThemeByDataAttribute } from '@storybook/addon-themes';
5
+
6
+ const preview: Preview = {
7
+ decorators: [
8
+ withThemeByDataAttribute({
9
+ themes: {
10
+ light: 'day',
11
+ dark: 'night',
12
+ },
13
+ defaultTheme: 'light',
14
+ attributeName: "data-theme",
15
+ }),
16
+ ],
17
+ parameters: {
18
+ actions: { argTypesRegex: "^on[A-Z].*" },
19
+ controls: {
20
+ matchers: {
21
+ color: /(background|color)$/i,
22
+ date: /Date$/,
23
+ },
24
+ },
25
+ }
26
+ };
27
+
28
+ export default preview;
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @local-logic/maps
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1443f96: LL22-5129: Moved maps package into its own library
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @local-logic/maps
2
+
3
+ A maps library supporting Maplibre, Mapbox and Google Maps functionality.
@@ -0,0 +1,46 @@
1
+ /* eslint-disable no-underscore-dangle */
2
+ import { defineConfig, globalIgnores } from "eslint/config";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import js from "@eslint/js";
6
+ import { FlatCompat } from "@eslint/eslintrc";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+ const compat = new FlatCompat({
11
+ baseDirectory: __dirname,
12
+ recommendedConfig: js.configs.recommended,
13
+ allConfig: js.configs.all,
14
+ });
15
+
16
+ export default defineConfig([
17
+ {
18
+ extends: compat.extends(
19
+ "@local-logic/eslint-config/react",
20
+ "plugin:storybook/recommended",
21
+ ),
22
+
23
+ settings: {
24
+ "import/resolver": {
25
+ typescript: {},
26
+ node: {
27
+ extensions: [".js", ".jsx", ".ts", ".tsx"],
28
+ },
29
+ },
30
+ },
31
+ },
32
+ {
33
+ files: ["**/*.tsx"],
34
+ rules: {
35
+ "react/prop-types": "off",
36
+ "node/file-extension-in-import": "off",
37
+ },
38
+ },
39
+ {
40
+ files: ["**/*.cjs"],
41
+ rules: {
42
+ "@typescript-eslint/no-require-imports": "off",
43
+ },
44
+ },
45
+ globalIgnores(["dist/"]),
46
+ ]);
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ "*.{js,ts}": ["eslint --fix", "prettier --write"],
3
+ "**/*.ts": () => "tsc -p tsconfig.json --noEmit",
4
+ };
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@local-logic/maps",
3
+ "version": "0.0.1",
4
+ "description": "This is a maps implementation allowing for the display of Local Logic data on a map.",
5
+ "author": "Local Logic",
6
+ "license": "ISC",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "main": "./dist/index.js",
13
+ "packageManager": "yarn@4.5.1",
14
+ "scripts": {
15
+ "dev": "vite",
16
+ "build": "vite build",
17
+ "test": "vitest run",
18
+ "stats": "STATS=1 vite build",
19
+ "size": "yarn run build && size-limit",
20
+ "lint": "TIMING=1 eslint --ext .js,.jsx,.ts,.tsx .",
21
+ "check-types": "tsc --project ./tsconfig.json --noEmit",
22
+ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
23
+ "storybook:run": "storybook dev -p 6006",
24
+ "storybook:build": "storybook build"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=18",
28
+ "react-dom": ">=18"
29
+ },
30
+ "devDependencies": {
31
+ "@eslint/eslintrc": "^3.3.0",
32
+ "@eslint/js": "^9.22.0",
33
+ "@local-logic/eslint-config": "*",
34
+ "@storybook/addon-actions": "^8.6.7",
35
+ "@storybook/addon-essentials": "^8.6.7",
36
+ "@storybook/addon-interactions": "^8.6.7",
37
+ "@storybook/addon-links": "^8.6.7",
38
+ "@storybook/addon-themes": "^8.6.7",
39
+ "@storybook/blocks": "^8.6.7",
40
+ "@storybook/builder-vite": "^8.6.7",
41
+ "@storybook/react": "^8.6.7",
42
+ "@storybook/react-vite": "^8.6.7",
43
+ "@storybook/testing-library": "^0.2.2",
44
+ "@testing-library/react": "^16.2.0",
45
+ "@types/geojson": "^7946.0.16",
46
+ "@types/react": "^19.0.11",
47
+ "@types/react-dom": "^19.0.4",
48
+ "@types/supercluster": "^7.1.3",
49
+ "@typescript-eslint/eslint-plugin": "^8.27.0",
50
+ "@typescript-eslint/parser": "^8.27.0",
51
+ "@vitejs/plugin-react": "^4.3.4",
52
+ "eslint": "^9.22.0",
53
+ "eslint-plugin-import": "^2.31.0",
54
+ "eslint-plugin-storybook": "^0.11.6",
55
+ "react": "^19.0.0",
56
+ "react-dom": "^19.0.0",
57
+ "rollup-plugin-visualizer": "^5.14.0",
58
+ "storybook": "^8.6.7",
59
+ "tsconfig": "*",
60
+ "typescript": "^5.8.2",
61
+ "vite": "^6.2.2",
62
+ "vite-plugin-dts": "^4.5.3",
63
+ "vite-plugin-svgr": "^4.3.0",
64
+ "vitest": "^3.0.9"
65
+ },
66
+ "dependencies": {
67
+ "@local-logic/core": "*",
68
+ "@local-logic/design-system": "*",
69
+ "@phosphor-icons/react": "^2.1.7",
70
+ "@turf/turf": "^7.2.0",
71
+ "@vis.gl/react-google-maps": "^1.5.2",
72
+ "mapbox-gl": "^3.10.0",
73
+ "maplibre-gl": "^5.2.0",
74
+ "react-map-gl": "^8.0.1",
75
+ "supercluster": "^8.0.1",
76
+ "use-supercluster": "^1.2.0"
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -0,0 +1 @@
1
+ module.exports = require("@local-logic/eslint-config/prettier.config");
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+
3
+ export default function EmptyBaseMap() {
4
+ return (
5
+ <div>Invalid map provider</div>
6
+ )
7
+ }
@@ -0,0 +1,96 @@
1
+ import React, { useState, useEffect } from "react";
2
+
3
+ import {
4
+ APIProvider,
5
+ Map as GoogleMap,
6
+ ControlPosition,
7
+ useMap,
8
+ } from "@vis.gl/react-google-maps";
9
+
10
+ import { useRootElement } from "../../context";
11
+ import { defaultMapValues } from "../../constants";
12
+
13
+ import type { BaseMapProps } from "../types";
14
+
15
+ const zoomPositionMap = {
16
+ "top-left": ControlPosition.LEFT_TOP,
17
+ "top-right": ControlPosition.RIGHT_TOP,
18
+ "bottom-left": ControlPosition.LEFT_BOTTOM,
19
+ "bottom-right": ControlPosition.RIGHT_BOTTOM,
20
+ };
21
+ const mapId = "a7ff20eb973126bb";
22
+
23
+ const MapControl = ({ isMapLoaded }: { isMapLoaded: boolean }) => {
24
+ const map = useMap();
25
+
26
+ const { setBounds } = useRootElement();
27
+
28
+ useEffect(() => {
29
+ if (!isMapLoaded || !map) {
30
+ return;
31
+ }
32
+
33
+ const bounds = map.getBounds()?.toJSON();
34
+
35
+ if (bounds) {
36
+ setBounds([bounds.west, bounds.south, bounds.east, bounds.north]);
37
+ }
38
+ }, [isMapLoaded, map]);
39
+
40
+ return null;
41
+ };
42
+
43
+ /**
44
+ * Map IDS:
45
+ * https://console.cloud.google.com/google/maps-apis/studio/maps?project=engineering-internal-396021
46
+ *
47
+ * Map styles:
48
+ * https://console.cloud.google.com/google/maps-apis/studio/styles?project=engineering-internal-396021
49
+ */
50
+ export default function GoogleBaseMap({ children }: BaseMapProps) {
51
+ const [isMapLoaded, setIsMapLoaded] = useState(false);
52
+ const {
53
+ mapProvider,
54
+ center,
55
+ zoom = defaultMapValues.zoom,
56
+ pitch = defaultMapValues.pitch,
57
+ bearing = defaultMapValues.bearing,
58
+ cooperativeGestures = defaultMapValues.cooperativeGestures,
59
+ zoomPosition,
60
+ } = useRootElement();
61
+
62
+ if (mapProvider.name !== "google") {
63
+ return null;
64
+ }
65
+
66
+ const handleMapLoaded = () => {
67
+ setIsMapLoaded(true);
68
+ };
69
+
70
+ return (
71
+ <APIProvider apiKey={mapProvider.apiKey}>
72
+ <MapControl isMapLoaded={isMapLoaded} />
73
+ <GoogleMap
74
+ mapId={mapId}
75
+ defaultCenter={{
76
+ lat: center.latitude,
77
+ lng: center.longitude,
78
+ }}
79
+ defaultZoom={zoom}
80
+ defaultTilt={pitch}
81
+ defaultHeading={bearing}
82
+ gestureHandling={!cooperativeGestures ? "greedy" : "cooperative"}
83
+ streetViewControl={false}
84
+ fullscreenControl={false}
85
+ clickableIcons={false}
86
+ mapTypeControl={false}
87
+ zoomControlOptions={{
88
+ position: zoomPosition && zoomPositionMap[zoomPosition],
89
+ }}
90
+ onTilesLoaded={handleMapLoaded}
91
+ >
92
+ {children}
93
+ </GoogleMap>
94
+ </APIProvider>
95
+ );
96
+ }
@@ -0,0 +1,66 @@
1
+ import React, { useEffect } from "react";
2
+
3
+ import Mapbox, { NavigationControl, useMap } from "react-map-gl/mapbox";
4
+ import "mapbox-gl/dist/mapbox-gl.css";
5
+
6
+ import type { BBox } from "geojson";
7
+
8
+ import { useRootElement } from "../../context";
9
+ import { defaultMapValues } from "../../constants";
10
+
11
+ import type { BaseMapProps } from "../types";
12
+
13
+ const MapControl = () => {
14
+ const { current: map } = useMap();
15
+ const { setBounds } = useRootElement();
16
+
17
+ useEffect(() => {
18
+ if (!map) {
19
+ return;
20
+ }
21
+
22
+ const bounds = map.getBounds();
23
+
24
+ if (!bounds) {
25
+ return;
26
+ }
27
+
28
+ setBounds(bounds.toArray().flat() as BBox);
29
+ }, [map]);
30
+
31
+ return null;
32
+ };
33
+
34
+ export default function MapboxBaseMap({ children }: BaseMapProps) {
35
+ const {
36
+ mapProvider,
37
+ center,
38
+ zoom = defaultMapValues.zoom,
39
+ pitch = defaultMapValues.pitch,
40
+ bearing = defaultMapValues.bearing,
41
+ cooperativeGestures = defaultMapValues.cooperativeGestures,
42
+ zoomPosition,
43
+ } = useRootElement();
44
+
45
+ if (mapProvider.name !== "mapbox") {
46
+ return null;
47
+ }
48
+
49
+ return (
50
+ <Mapbox
51
+ mapboxAccessToken={mapProvider.apiKey}
52
+ initialViewState={{
53
+ ...center,
54
+ zoom,
55
+ pitch,
56
+ bearing,
57
+ }}
58
+ cooperativeGestures={cooperativeGestures}
59
+ mapStyle={`mapbox://styles/mapbox/streets-v9`}
60
+ >
61
+ <MapControl />
62
+ {zoomPosition && <NavigationControl position={zoomPosition} />}
63
+ {children}
64
+ </Mapbox>
65
+ );
66
+ }
@@ -0,0 +1,59 @@
1
+ import React, { useEffect } from "react";
2
+
3
+ import Maplibre, { NavigationControl, useMap } from "react-map-gl/maplibre";
4
+ import "maplibre-gl/dist/maplibre-gl.css";
5
+
6
+ import type { BBox } from "geojson";
7
+
8
+ import { useRootElement } from "../../context";
9
+ import { defaultMapValues } from "../../constants";
10
+
11
+ import type { BaseMapProps } from "../types";
12
+
13
+ const MapControl = () => {
14
+ const { current: map } = useMap();
15
+ const { setBounds } = useRootElement();
16
+
17
+ useEffect(() => {
18
+ if (!map) {
19
+ return;
20
+ }
21
+
22
+ setBounds(map.getBounds().toArray().flat() as BBox);
23
+ }, [map]);
24
+
25
+ return null;
26
+ };
27
+
28
+ export default function MaptilerBaseMap({ children }: BaseMapProps) {
29
+ const {
30
+ mapProvider,
31
+ center,
32
+ zoom = defaultMapValues.zoom,
33
+ pitch = defaultMapValues.pitch,
34
+ bearing = defaultMapValues.bearing,
35
+ cooperativeGestures = defaultMapValues.cooperativeGestures,
36
+ zoomPosition,
37
+ } = useRootElement();
38
+
39
+ if (mapProvider.name !== "maptiler") {
40
+ return null;
41
+ }
42
+
43
+ return (
44
+ <Maplibre
45
+ initialViewState={{
46
+ ...center,
47
+ zoom,
48
+ pitch,
49
+ bearing,
50
+ }}
51
+ cooperativeGestures={cooperativeGestures}
52
+ mapStyle={`https://api.maptiler.com/maps/${mapProvider.maptilerTheme}/style.json?key=${mapProvider.apiKey}`}
53
+ >
54
+ <MapControl />
55
+ {zoomPosition && <NavigationControl position={zoomPosition} />}
56
+ {children}
57
+ </Maplibre>
58
+ );
59
+ }
@@ -0,0 +1,36 @@
1
+ import React, { lazy, useMemo } from "react";
2
+
3
+ import { useRootElement } from "../context";
4
+
5
+ import type { BaseMapProps } from "./types";
6
+ import * as styles from "./styles";
7
+
8
+ const MaptilerBaseMap = lazy(() => import("./Maptiler"));
9
+ const GoogleBaseMap = lazy(() => import("./Google"));
10
+ const MapboxBaseMap = lazy(() => import("./Mapbox"));
11
+ const EmptyBaseMap = lazy(() => import("./Empty"));
12
+
13
+ export const BaseMap = ({ children }: BaseMapProps) => {
14
+ const { mapProvider } = useRootElement();
15
+
16
+ const BaseMapProvider = useMemo(() => {
17
+ switch (mapProvider?.name) {
18
+ case "maptiler":
19
+ return MaptilerBaseMap;
20
+ case "google":
21
+ return GoogleBaseMap;
22
+ case "mapbox":
23
+ return MapboxBaseMap;
24
+ default:
25
+ return EmptyBaseMap;
26
+ }
27
+ }, [mapProvider]);
28
+
29
+ return (
30
+ <div className={styles.wrapper}>
31
+ <BaseMapProvider>
32
+ {children}
33
+ </BaseMapProvider>
34
+ </div>
35
+ );
36
+ };
@@ -0,0 +1,2 @@
1
+ export const wrapper = `relative w-full h-full`;
2
+ export const map = `absolute w-full h-full`;
@@ -0,0 +1,3 @@
1
+ import type { RootProps } from "../types";
2
+
3
+ export type BaseMapProps = Pick<RootProps, "children">;
@@ -0,0 +1,26 @@
1
+ import React, { useMemo } from "react";
2
+
3
+ import type { ClusterPoint } from "../types";
4
+
5
+ import * as styles from "./styles";
6
+
7
+ export function Cluster({ cluster }: { cluster: ClusterPoint }) {
8
+ // Taking a somewhat similar calculation to what LocalLogic SDK used to
9
+ // do for cluster diameter (bigger depending on count)
10
+ const diameter = useMemo(() => (
11
+ 20 + cluster.properties.point_count * 4
12
+ ), [cluster.properties.point_count]);
13
+
14
+ return (
15
+ <div
16
+ className={styles.container}
17
+ style={{
18
+ width: `${diameter}px`,
19
+ height: `${diameter}px`,
20
+ fontSize: `${diameter / 2.5}px`
21
+ }}
22
+ >
23
+ {cluster.properties.point_count}
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,14 @@
1
+ export const container = `
2
+ rounded-full
3
+ bg-primary-100
4
+ text-base-white
5
+ flex
6
+ justify-center
7
+ items-center
8
+ leading-none
9
+ font-sans
10
+ border-2
11
+ border-solid
12
+ border-base-white
13
+ shadow-sm
14
+ `;
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+
3
+ import { MapPin } from "@phosphor-icons/react";
4
+ import { AdvancedMarker as GoogleMarker } from "@vis.gl/react-google-maps";
5
+
6
+ import type { MapMarkerProps } from "../types";
7
+
8
+ import { Cluster } from "../Cluster";
9
+
10
+ import * as globalStyles from "../styles";
11
+ import * as styles from "./styles";
12
+
13
+ export default function GoogleMarkers({ clusters, children }: MapMarkerProps) {
14
+ return clusters.map((cluster, index) => {
15
+ const longitude = cluster.geometry.coordinates[0];
16
+ const latitude = cluster.geometry.coordinates[1];
17
+
18
+ const handleOnClick = () => {
19
+ // TODO Without the on click the hover doesn't work
20
+ };
21
+
22
+ return (
23
+ <GoogleMarker
24
+ key={`map-marker-${longitude}-${latitude}-${index}`}
25
+ position={{
26
+ lat: latitude,
27
+ lng: longitude,
28
+ }}
29
+ onClick={handleOnClick}
30
+ className={styles.marker}
31
+ >
32
+ <div className={globalStyles.container}>
33
+ {cluster.properties.cluster ? (
34
+ <Cluster cluster={cluster} />
35
+ ) : (
36
+ <>
37
+ {cluster.properties.icon ? (
38
+ <cluster.properties.icon className={globalStyles.icon} />
39
+ ) : (
40
+ <MapPin className={globalStyles.icon} />
41
+ )}
42
+ {children}
43
+ </>
44
+ )}
45
+ </div>
46
+ </GoogleMarker>
47
+ );
48
+ });
49
+ }
@@ -0,0 +1 @@
1
+ export const marker = `-translate-y-1/2`;
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+
3
+ import { MapPin } from "@phosphor-icons/react";
4
+ import { Marker as MapboxMarker } from "react-map-gl/mapbox";
5
+
6
+ import type { MapMarkerProps } from "../types";
7
+
8
+ import { Cluster } from "../Cluster";
9
+
10
+ import * as styles from "../styles";
11
+
12
+ export default function MapboxMarkers({ clusters, children }: MapMarkerProps) {
13
+ return clusters.map((cluster, index) => {
14
+ const longitude = cluster.geometry.coordinates[0];
15
+ const latitude = cluster.geometry.coordinates[1];
16
+
17
+ return (
18
+ <MapboxMarker
19
+ key={`map-marker-${longitude}-${latitude}-${index}`}
20
+ anchor="bottom"
21
+ longitude={longitude}
22
+ latitude={latitude}
23
+ >
24
+ <div className={styles.container}>
25
+ {cluster.properties.cluster ? (
26
+ <Cluster cluster={cluster} />
27
+ ) : (
28
+ <>
29
+ {cluster.properties.icon ? (
30
+ <cluster.properties.icon className={styles.icon} />
31
+ ) : (
32
+ <MapPin className={styles.icon} />
33
+ )}
34
+ {children}
35
+ </>
36
+ )}
37
+ </div>
38
+ </MapboxMarker>
39
+ );
40
+ });
41
+ }
@@ -0,0 +1,44 @@
1
+ import React from "react";
2
+
3
+ import { MapPin } from "@phosphor-icons/react";
4
+ import { Marker as MaplibreMarker } from "react-map-gl/maplibre";
5
+
6
+ import type { MapMarkerProps } from "../types";
7
+
8
+ import { Cluster } from "../Cluster";
9
+
10
+ import * as styles from "../styles";
11
+
12
+ export default function MaptilerMarkers({
13
+ clusters,
14
+ children,
15
+ }: MapMarkerProps) {
16
+ return clusters?.map((cluster, index) => {
17
+ const longitude = cluster.geometry.coordinates[0];
18
+ const latitude = cluster.geometry.coordinates[1];
19
+
20
+ return (
21
+ <MaplibreMarker
22
+ key={`map-marker-${longitude}-${latitude}-${index}`}
23
+ anchor="bottom"
24
+ longitude={longitude}
25
+ latitude={latitude}
26
+ >
27
+ <div className={styles.container}>
28
+ {cluster.properties.cluster ? (
29
+ <Cluster cluster={cluster} />
30
+ ) : (
31
+ <>
32
+ {cluster.properties.icon ? (
33
+ <cluster.properties.icon className={styles.icon} />
34
+ ) : (
35
+ <MapPin className={styles.icon} />
36
+ )}
37
+ {children}
38
+ </>
39
+ )}
40
+ </div>
41
+ </MaplibreMarker>
42
+ );
43
+ });
44
+ }
@@ -0,0 +1,61 @@
1
+ import React, { lazy, useMemo, useEffect } from "react";
2
+
3
+ import type { PointFeature, AnyProps } from "supercluster";
4
+ import { point } from "@turf/turf";
5
+
6
+ import useSupercluser from "use-supercluster";
7
+
8
+ import { useRootElement } from "../context";
9
+
10
+ import type { MarkerProps, ClusterPoint } from "./types";
11
+
12
+ import * as styles from "./styles";
13
+
14
+ const MaptilerMarkers = lazy(() => import("./Maptiler"));
15
+ const GoogleMarkers = lazy(() => import("./Google"));
16
+ const MapboxMarkers = lazy(() => import("./Mapbox"));
17
+
18
+ export const Markers = ({ markers, ...rest }: MarkerProps) => {
19
+ const { mapProvider, bounds, setBounds } = useRootElement();
20
+ const { clusters, supercluster } = useSupercluser({
21
+ points: markers.map(marker => (point([marker.longitude, marker.latitude], {
22
+ icon: marker.icon,
23
+ }))),
24
+ bounds,
25
+ zoom: 16,
26
+ options: { radius: 50, maxZoom: 30 },
27
+ });
28
+
29
+ const BaseMarkers = useMemo(() => {
30
+ switch (mapProvider?.name) {
31
+ case "maptiler":
32
+ return MaptilerMarkers;
33
+ case "google":
34
+ return GoogleMarkers;
35
+ case "mapbox":
36
+ return MapboxMarkers;
37
+ default:
38
+ return null;
39
+ }
40
+ }, [mapProvider]);
41
+
42
+ if (!BaseMarkers) {
43
+ return null;
44
+ }
45
+
46
+ return (
47
+ <BaseMarkers clusters={clusters as ClusterPoint[]} {...rest}>
48
+ {/* POI Pin Background */}
49
+ <svg
50
+ viewBox="0 0 20 28"
51
+ fill="none"
52
+ className={styles.background}
53
+ >
54
+ <path
55
+ d="M20 10C20 15.5228 12 27.5 10 27.5C8 27.5 0 15.5228 0 10C0 4.47715 4.47715 0 10 0C15.5228 0 20 4.47715 20 10Z"
56
+ fill="inherit"
57
+ />
58
+ </svg>
59
+ </BaseMarkers>
60
+ )
61
+ };
@@ -0,0 +1,29 @@
1
+ export const container = `
2
+ cursor-pointer
3
+ relative
4
+ h-8
5
+ w-6
6
+ text-base-white
7
+ group
8
+ transition-transform
9
+ hover:scale-125
10
+ `;
11
+
12
+ export const background = `
13
+ cursor-pointer
14
+ fill-primary-100
15
+ stroke-1
16
+ box-border
17
+ transition-colors
18
+ transition-transform
19
+ group-hover:stroke-base-white
20
+ group-focus:stroke-base-white
21
+ `;
22
+
23
+ export const icon = `
24
+ absolute
25
+ top-0 left-0 right-0 bottom-1
26
+ transition-transform
27
+ m-auto
28
+ h-4 w-4
29
+ `;
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ import type { PointFeature, ClusterProperties } from "supercluster";
3
+ import type { Marker } from "../types";
4
+
5
+ export type ClusterPoint = PointFeature<ClusterProperties & Marker>;
6
+
7
+ export type MarkerProps = {
8
+ markers: Marker[];
9
+ onClick?: (marker: Marker) => void;
10
+ children?: ReactNode;
11
+ };
12
+
13
+ export type MapMarkerProps = Omit<MarkerProps, "markers"> & {
14
+ clusters: ClusterPoint[];
15
+ };
@@ -0,0 +1,6 @@
1
+ export const defaultMapValues = {
2
+ zoom: 16,
3
+ pitch: 0,
4
+ bearing: 0,
5
+ cooperativeGestures: false,
6
+ };
@@ -0,0 +1,15 @@
1
+ import React, { useContext } from "react";
2
+ import type { BBox } from "geojson";
3
+ import type { RootProps } from "./types";
4
+
5
+ export type DefaultContext = RootProps & {
6
+ bounds?: BBox;
7
+ setBounds: (bounds: BBox) => void;
8
+ };
9
+
10
+ const RootElementContext = React.createContext<DefaultContext>(
11
+ {} as DefaultContext,
12
+ );
13
+ const useRootElement = () => useContext(RootElementContext);
14
+
15
+ export { useRootElement, RootElementContext };
@@ -0,0 +1,17 @@
1
+ import React, { useState } from "react";
2
+ import type { RootProps } from "./types";
3
+ import { RootElementContext, type DefaultContext } from "./context";
4
+
5
+ export function Root(props: RootProps) {
6
+ const [bounds, setBounds] = useState<DefaultContext["bounds"]>();
7
+
8
+ return (
9
+ <RootElementContext.Provider value={{ ...props, bounds, setBounds }}>
10
+ {props.children}
11
+ </RootElementContext.Provider>
12
+ );
13
+ }
14
+
15
+ export { BaseMap } from "./BaseMap";
16
+ export { Markers } from "./Markers";
17
+ export type { MarkerProps } from "./Markers/types";
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from "react";
2
+ import { Icon } from "@phosphor-icons/react";
3
+
4
+ /**
5
+ * We should try to keep the types "agnostic" but if we have to pick a side,
6
+ * we should side with the maptiler namings.
7
+ */
8
+
9
+ export type MapProvider = {
10
+ apiKey: string;
11
+ name: "maptiler" | "google" | "mapbox";
12
+ maptilerTheme?: string;
13
+ };
14
+
15
+ export type Coordinates = {
16
+ latitude: number;
17
+ longitude: number;
18
+ };
19
+
20
+ export type Marker = {
21
+ latitude: Coordinates["latitude"];
22
+ longitude: Coordinates["longitude"];
23
+ icon?: Icon;
24
+ };
25
+
26
+ export type ZoomPosition =
27
+ | "top-left"
28
+ | "top-right"
29
+ | "bottom-left"
30
+ | "bottom-right";
31
+
32
+ export type RootProps = {
33
+ mapProvider: MapProvider;
34
+ center: Coordinates;
35
+ zoom?: number;
36
+ zoomPosition?: ZoomPosition;
37
+ pitch?: number;
38
+ bearing?: number;
39
+ cooperativeGestures?: boolean;
40
+ markers?: Marker[];
41
+ children?: ReactNode;
42
+ };
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { StoryFn, Meta } from "@storybook/react";
3
+ import { mapDefaults as storybookMapDefaults } from "~/../.storybook/defaults";
4
+ import type { RootProps, Marker } from "./Root/types";
5
+ import { defaultMapValues } from "./Root/constants";
6
+ import * as Map from "./Root";
7
+
8
+ export default {
9
+ title: "Map",
10
+ component: Map.Root,
11
+ argTypes: storybookMapDefaults,
12
+ } as Meta<RootProps>;
13
+
14
+ const defaultValues = {
15
+ ...defaultMapValues,
16
+ center: {
17
+ latitude: 45.5282164,
18
+ longitude: -73.5978527,
19
+ },
20
+ zoomPosition: "bottom-right" as RootProps["zoomPosition"],
21
+ };
22
+
23
+ const markers = [
24
+ {
25
+ latitude: 45.527399,
26
+ longitude: -73.598126,
27
+ // icon: "Storefront",
28
+ },
29
+ {
30
+ latitude: 45.527302,
31
+ longitude: -73.597405,
32
+ // icon: "CartPlusInside",
33
+ },
34
+ {
35
+ latitude: 45.527302,
36
+ longitude: -73.597405,
37
+ // icon: "Brandy",
38
+ },
39
+ {
40
+ latitude: 45.527302,
41
+ longitude: -73.597405,
42
+ // icon: "SpoonAndFork",
43
+ },
44
+ {
45
+ latitude: 45.527302,
46
+ longitude: -73.597405,
47
+ // icon: "Coffee",
48
+ },
49
+ {
50
+ latitude: 45.527302,
51
+ longitude: -73.597405,
52
+ // icon: "Baby",
53
+ },
54
+ {
55
+ latitude: 45.527256,
56
+ longitude: -73.600229,
57
+ // icon: "Train",
58
+ },
59
+ {
60
+ latitude: 45.527256,
61
+ longitude: -73.600229,
62
+ // icon: "Barbell",
63
+ },
64
+ {
65
+ latitude: 45.526643,
66
+ longitude: -73.600293,
67
+ // icon: "GasPump",
68
+ },
69
+ {
70
+ latitude: 45.527025,
71
+ longitude: -73.600897,
72
+ // icon: "Heartbeat",
73
+ },
74
+ ] as Marker[];
75
+
76
+ const maptilerThemes = {
77
+ day: "600d69cb-288d-445e-9839-3dfe4d76b31a",
78
+ night: "dd191599-2a92-49fc-9a33-e12391753ad5",
79
+ };
80
+
81
+ const Template: StoryFn<RootProps> = (args) => (
82
+ <div className="w-full h-[calc(100vh-30px)]">
83
+ <Map.Root {...args}>
84
+ <Map.BaseMap>
85
+ <Map.Markers markers={markers} />
86
+ </Map.BaseMap>
87
+ </Map.Root>
88
+ </div>
89
+ );
90
+
91
+ export const Maptiler = Template.bind({});
92
+ Maptiler.args = {
93
+ mapProvider: {
94
+ name: "maptiler",
95
+ apiKey: import.meta.env.VITE_MAPTILER_KEY,
96
+ maptilerTheme: maptilerThemes.day,
97
+ },
98
+ ...defaultValues,
99
+ };
100
+
101
+ export const Mapbox = Template.bind({});
102
+ Mapbox.args = {
103
+ mapProvider: {
104
+ name: "mapbox",
105
+ apiKey: import.meta.env.VITE_MAPBOX_KEY,
106
+ },
107
+ ...defaultValues,
108
+ };
109
+
110
+ export const Google = Template.bind({});
111
+ Google.args = {
112
+ mapProvider: {
113
+ name: "google",
114
+ apiKey: import.meta.env.VITE_GOOGLE_KEY,
115
+ },
116
+ ...defaultValues,
117
+ };
@@ -0,0 +1,16 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // filepath: /Users/corado/dev/client-packages/packages/maps/src/components/Map/index.test.tsx
4
+
5
+ // Mock the "./Root" module
6
+ vi.mock("./Root", () => ({
7
+ __esModule: true,
8
+ default: "MockedRoot",
9
+ }));
10
+
11
+ describe("Map Component Exports", () => {
12
+ it("should export Root from './Root'", async () => {
13
+ const { Root } = await import("./index");
14
+ expect(Root).toBeDefined();
15
+ });
16
+ });
@@ -0,0 +1 @@
1
+ export * as Root from "./Root";
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * as Map from "~/components/Map";
@@ -0,0 +1 @@
1
+ module.exports = require("@local-logic/design-system/tailwind.config.cjs");
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "tsconfig/react-library.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "experimentalDecorators": true,
8
+ "paths": {
9
+ "~/*": ["./src/*"]
10
+ }
11
+ },
12
+ "include": ["./src/**/*", "./vite-env.d.ts"],
13
+ "exclude": ["dist", "build", "node_modules"]
14
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "module": "esnext",
5
+ "moduleResolution": "node"
6
+ },
7
+ "include": ["vite.config.ts"]
8
+ }
package/vite-env.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /// <reference types="vite-plugin-svgr/client" />
2
+ /// <reference types="vite/client" />
3
+
4
+ interface ImportMetaEnv {
5
+ readonly VITE_MAPTILER_KEY: string;
6
+ readonly VITE_MAPBOX_KEY: string;
7
+ readonly VITE_GOOGLE_KEY: string;
8
+ // more env variables...
9
+ }
10
+
11
+ interface ImportMeta {
12
+ readonly env: ImportMetaEnv;
13
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,46 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { resolve } from "path";
3
+ import { visualizer } from "rollup-plugin-visualizer";
4
+ import { defineConfig, PluginOption } from "vite";
5
+ import svgr from "vite-plugin-svgr";
6
+ import dts from "vite-plugin-dts";
7
+
8
+ // https://vitejs.dev/config/
9
+ export default defineConfig({
10
+ build: {
11
+ lib: {
12
+ entry: resolve(__dirname, "src/index.ts"),
13
+ fileName: (format) => `index.${format}.js`,
14
+ name: "LocalLogicMaps",
15
+ },
16
+ /**
17
+ * This option will only minify the UMD output. Package users can still see
18
+ * the unminified "index.es.js" output.
19
+ */
20
+ minify: true,
21
+ },
22
+ resolve: {
23
+ alias: {
24
+ "~": resolve(__dirname, "./src"),
25
+ },
26
+ },
27
+ plugins: [
28
+ svgr({
29
+ include: "**/*.svg",
30
+ }),
31
+ /**
32
+ * Generate typings
33
+ */
34
+ dts({
35
+ insertTypesEntry: true,
36
+ }),
37
+
38
+ // Has to be after generating the types
39
+ react(),
40
+
41
+ // This option MUST come last. Note that build size WILL NOT represent build
42
+ // size in client app because it is not minified (client app should handle
43
+ // minification).
44
+ visualizer({ brotliSize: true, open: !!process.env.STATS }) as PluginOption,
45
+ ],
46
+ });