diff --git a/app/scripts/components/common/copy-field.ts b/app/scripts/components/common/copy-field.ts new file mode 100644 index 000000000..8d2a71756 --- /dev/null +++ b/app/scripts/components/common/copy-field.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react'; +import Clipboard from 'clipboard'; + +interface CopyFieldProps { + value: string; + children: (props: { + value: string; + ref: React.MutableRefObject; + originalValue: string; + showCopiedMsg: boolean; + }) => JSX.Element; +} + +export function CopyField(props: CopyFieldProps) { + const { value, children } = props; + + const [showCopiedMsg, setShowCopiedMsg] = useState(false); + const triggerElement = useRef(); + + const copyValue = useRef(value); + copyValue.current = value; + + useEffect(() => { + if (!triggerElement.current) throw new Error("ref for trigger element is not set"); + + let copiedMsgTimeout: NodeJS.Timeout | undefined; + const clipboard = new Clipboard(triggerElement.current, { + text: () => copyValue.current + }); + + clipboard.on('success', () => { + setShowCopiedMsg(true); + copiedMsgTimeout = setTimeout(() => { + setShowCopiedMsg(false); + }, 2000); + }); + + return () => { + clipboard.destroy(); + if (copiedMsgTimeout) { + clearTimeout(copiedMsgTimeout); + } + }; + }, []); + + const val = showCopiedMsg ? 'Copied!' : value; + + return children({ + value: val, + ref: triggerElement, + originalValue: value, + showCopiedMsg + }); +} diff --git a/app/scripts/components/common/mapbox/map-coords.tsx b/app/scripts/components/common/mapbox/map-coords.tsx new file mode 100644 index 000000000..524cbfbb5 --- /dev/null +++ b/app/scripts/components/common/mapbox/map-coords.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Map } from 'mapbox-gl'; + +import { themeVal } from '@devseed-ui/theme-provider'; +import { Button } from '@devseed-ui/button'; + +import { CopyField } from '../copy-field'; +import { round } from '$utils/format'; + +const MapCoordsWrapper = styled.div` + /* Large width so parent will wrap */ + width: 100vw; + + ${Button} { + background: ${themeVal('color.base-400a')}; + font-weight: ${themeVal('type.base.regular')}; + font-size: 0.75rem; + } + + && ${Button /* sc-selector */}:hover { + background: ${themeVal('color.base-500')}; + } +`; + +interface MapCoordsProps { + mapInstance: Map; +} + +const getCoords = (mapInstance: Map) => { + const mapCenter = mapInstance.getCenter(); + return { + lng: round(mapCenter.lng, 4), + lat: round(mapCenter.lat, 4) + }; +}; + +export default function MapCoords(props: MapCoordsProps) { + const { mapInstance } = props; + + const [position, setPosition] = useState(getCoords(mapInstance)); + + useEffect(() => { + const posListener = (e) => { + setPosition(getCoords(e.target)); + }; + + mapInstance.on('moveend', posListener); + + return () => { + mapInstance.off('moveend', posListener); + }; + }, [mapInstance]); + + const { lng, lat } = position; + const value = `W ${lng}, N ${lat}`; + + return ( + + + {({ ref, showCopiedMsg }) => ( + + )} + + + ); +} diff --git a/app/scripts/components/common/mapbox/map.tsx b/app/scripts/components/common/mapbox/map.tsx index 5a986d8e1..d1ab343ba 100644 --- a/app/scripts/components/common/mapbox/map.tsx +++ b/app/scripts/components/common/mapbox/map.tsx @@ -24,6 +24,7 @@ import { aoiCursorStyles, useMbDraw } from './aoi/mb-aoi-draw'; import MapOptions from './map-options'; import { useMapboxControl } from './use-mapbox-control'; import { convertProjectionToMapbox } from './map-options/utils'; +import MapCoords from './map-coords'; import { useMapStyle } from './layers/styles'; import { BasemapId, Option } from './map-options/basemaps'; @@ -116,6 +117,12 @@ export function SimpleMap(props: SimpleMapProps): ReactElement { onOptionChange ]); + const mapCoordsControl = useMapboxControl(() => { + if (!mapRef.current) return null; + + return ; + }, []); + const { style } = useMapStyle(); useEffect(() => { @@ -130,6 +137,8 @@ export function SimpleMap(props: SimpleMapProps): ReactElement { mapRef.current = mbMap; + mapRef.current.addControl(mapCoordsControl, 'bottom-left'); + if (onProjectionChange && projection) { mapRef.current.addControl(mapOptionsControl, 'top-left'); } diff --git a/app/scripts/components/common/mapbox/mapbox-style-override.js b/app/scripts/components/common/mapbox/mapbox-style-override.js index c2647ff7f..dc48b736f 100644 --- a/app/scripts/components/common/mapbox/mapbox-style-override.js +++ b/app/scripts/components/common/mapbox/mapbox-style-override.js @@ -78,7 +78,7 @@ const MapboxStyleOverride = css` /* stylelint-enable no-descending-specificity */ .mapboxgl-ctrl-bottom-left { - flex-direction: row; + flex-flow: row wrap; align-items: flex-end; align-items: center; } diff --git a/app/scripts/components/common/mapbox/use-mapbox-control.tsx b/app/scripts/components/common/mapbox/use-mapbox-control.tsx index f1b603328..409b3a03d 100644 --- a/app/scripts/components/common/mapbox/use-mapbox-control.tsx +++ b/app/scripts/components/common/mapbox/use-mapbox-control.tsx @@ -21,8 +21,12 @@ import { ThemeProvider, useTheme } from 'styled-components'; * // Add the control to mapbox * } */ -export function useMapboxControl(renderFn, deps: any[] = []) { +export function useMapboxControl( + renderFn: (el: HTMLDivElement) => React.ReactNode, + deps: any[] = [] +) { const rootRef = useRef(); + const elementRef = useRef(); const renderFnRef = useRef<() => void>(() => ({})); const theme = useTheme(); @@ -30,7 +34,7 @@ export function useMapboxControl(renderFn, deps: any[] = []) { renderFnRef.current = () => { if (!rootRef.current) return; rootRef.current.render( - {renderFn()} + {renderFn(elementRef.current!)} ); }; @@ -43,6 +47,7 @@ export function useMapboxControl(renderFn, deps: any[] = []) { onAdd() { const el = document.createElement('div'); el.className = 'mapboxgl-ctrl'; + elementRef.current = el; rootRef.current = createRoot(el); renderFnRef.current(); diff --git a/package.json b/package.json index cd2e34a71..d3a2ed678 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "@types/react-dom": "^18.2.5", "@types/styled-components": "^5.1.26", "axios": "^0.25.0", + "clipboard": "^2.0.11", "codemirror": "^6.0.1", "d3": "^7.6.1", "d3-scale-chromatic": "^3.0.0", @@ -139,7 +140,7 @@ "jest-environment-jsdom": "^28.1.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "mapbox-gl": "^2.11.0", + "mapbox-gl": "^2.15.0", "mapbox-gl-compare": "^0.4.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "papaparse": "^5.3.2", diff --git a/yarn.lock b/yarn.lock index 82dc27554..c20d61031 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1979,10 +1979,10 @@ dependencies: meow "^6.1.1" -"@mapbox/tiny-sdf@^2.0.5": - version "2.0.5" - resolved "http://verdaccio.ds.io:4873/@mapbox%2ftiny-sdf/-/tiny-sdf-2.0.5.tgz#cdba698d3d65087643130f9af43a2b622ce0b372" - integrity sha512-OhXt2lS//WpLdkqrzo/KwB7SRD8AiNTFFzuo9n14IBupzIMa67yGItcK7I2W9D8Ghpa4T04Sw9FWsKCJG50Bxw== +"@mapbox/tiny-sdf@^2.0.6": + version "2.0.6" + resolved "http://verdaccio.ds.io:4873/@mapbox%2ftiny-sdf/-/tiny-sdf-2.0.6.tgz#9a1d33e5018093e88f6a4df2343e886056287282" + integrity sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA== "@mapbox/unitbezier@^0.0.1": version "0.0.1" @@ -4651,6 +4651,15 @@ clean-stack@^2.0.0: resolved "http://verdaccio.ds.io:4873/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== +clipboard@^2.0.11: + version "2.0.11" + resolved "http://verdaccio.ds.io:4873/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5" + integrity sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + cliui@^3.2.0: version "3.2.0" resolved "http://verdaccio.ds.io:4873/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" @@ -5505,6 +5514,11 @@ delayed-stream@~1.0.0: resolved "http://verdaccio.ds.io:4873/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +delegate@^3.1.2: + version "3.2.0" + resolved "http://verdaccio.ds.io:4873/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + dequal@1.0.0: version "1.0.0" resolved "http://verdaccio.ds.io:4873/dequal/-/dequal-1.0.0.tgz#41c6065e70de738541c82cdbedea5292277a017e" @@ -6769,6 +6783,13 @@ gonzales-pe@^4.3.0: dependencies: minimist "^1.2.5" +good-listener@^1.2.2: + version "1.2.2" + resolved "http://verdaccio.ds.io:4873/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= + dependencies: + delegate "^3.1.2" + google-polyline@^1.0.3: version "1.0.3" resolved "http://verdaccio.ds.io:4873/google-polyline/-/google-polyline-1.0.3.tgz#b1fafd8841059f7049d4ba6767c386be979510dc" @@ -8244,10 +8265,10 @@ just-debounce@^1.0.0: resolved "http://verdaccio.ds.io:4873/just-debounce/-/just-debounce-1.1.0.tgz#2f81a3ad4121a76bc7cb45dbf704c0d76a8e5ddf" integrity sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ== -kdbush@^3.0.0: - version "3.0.0" - resolved "http://verdaccio.ds.io:4873/kdbush/-/kdbush-3.0.0.tgz#f8484794d47004cc2d85ed3a79353dbe0abc2bf0" - integrity sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew== +kdbush@^4.0.1, kdbush@^4.0.2: + version "4.0.2" + resolved "http://verdaccio.ds.io:4873/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" + integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== keyv@^4.0.0: version "4.3.2" @@ -8626,16 +8647,16 @@ mapbox-gl-draw-rectangle-mode@^1.0.4: resolved "http://verdaccio.ds.io:4873/mapbox-gl-draw-rectangle-mode/-/mapbox-gl-draw-rectangle-mode-1.0.4.tgz#42987d68872a5fb5cc5d76d3375ee20cd8bab8f7" integrity sha512-BdF6nwEK2p8n9LQoMPzBO8LhddW1fe+d5vK8HQIei+4VcRnUbKNsEj7Z15FsJxCHzsc2BQKXbESx5GaE8x0imQ== -mapbox-gl@^2.11.0: - version "2.11.0" - resolved "http://verdaccio.ds.io:4873/mapbox-gl/-/mapbox-gl-2.11.0.tgz#fa4839e81bdd6f6d9b9615ceee84bb88adb89b4c" - integrity sha512-zXbqHfEQMsta4iKO8bzXapDz1DihvZg24gmPJoQgUYAJxjc0NVVa5G/1QF3TIlpAJDD7WPzQSAogItab5oOF3A== +mapbox-gl@^2.15.0: + version "2.15.0" + resolved "http://verdaccio.ds.io:4873/mapbox-gl/-/mapbox-gl-2.15.0.tgz#9439828d0bae1e7b464ae08b30cb2e65a7e2256d" + integrity sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A== dependencies: "@mapbox/geojson-rewind" "^0.5.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" "@mapbox/mapbox-gl-supported" "^2.0.1" "@mapbox/point-geometry" "^0.1.0" - "@mapbox/tiny-sdf" "^2.0.5" + "@mapbox/tiny-sdf" "^2.0.6" "@mapbox/unitbezier" "^0.0.1" "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" @@ -8644,12 +8665,13 @@ mapbox-gl@^2.11.0: geojson-vt "^3.2.1" gl-matrix "^3.4.3" grid-index "^1.1.0" + kdbush "^4.0.1" murmurhash-js "^1.0.0" pbf "^3.2.1" potpack "^2.0.0" quickselect "^2.0.0" rw "^1.3.3" - supercluster "^7.1.5" + supercluster "^8.0.0" tinyqueue "^2.0.3" vt-pbf "^3.1.3" @@ -11222,6 +11244,11 @@ section-matter@^1.0.0: extend-shallow "^2.0.1" kind-of "^6.0.0" +select@^1.1.2: + version "1.1.2" + resolved "http://verdaccio.ds.io:4873/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + semver-greatest-satisfied-range@^1.1.0: version "1.1.0" resolved "http://verdaccio.ds.io:4873/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz#13e8c2658ab9691cb0cd71093240280d36f77a5b" @@ -11795,12 +11822,12 @@ suggestions@^1.6.0: fuzzy "^0.1.1" xtend "^4.0.0" -supercluster@^7.1.5: - version "7.1.5" - resolved "http://verdaccio.ds.io:4873/supercluster/-/supercluster-7.1.5.tgz#65a6ce4a037a972767740614c19051b64b8be5a3" - integrity sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg== +supercluster@^8.0.0: + version "8.0.1" + resolved "http://verdaccio.ds.io:4873/supercluster/-/supercluster-8.0.1.tgz#9946ba123538e9e9ab15de472531f604e7372df5" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== dependencies: - kdbush "^3.0.0" + kdbush "^4.0.2" supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" @@ -11951,6 +11978,11 @@ timsort@^0.3.0: resolved "http://verdaccio.ds.io:4873/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "http://verdaccio.ds.io:4873/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tinyqueue@^2.0.3: version "2.0.3" resolved "http://verdaccio.ds.io:4873/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"