diff --git a/packages/plugins/map/package.json b/packages/plugins/map/package.json index 4d0120bf0..5dc38b636 100644 --- a/packages/plugins/map/package.json +++ b/packages/plugins/map/package.json @@ -9,11 +9,22 @@ "main": "./lib/index.js", "types": "./lib/index.d.ts", "devDependencies": { - "@nocobase/server": "0.10.0-alpha.5", - "@nocobase/test": "0.10.0-alpha.5" + "@types/google.maps": "^3.53.4", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0" }, "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", - "@amap/amap-jsapi-types": "^0.0.10" + "@amap/amap-jsapi-types": "^0.0.10", + "@googlemaps/js-api-loader": "^1.16.1", + "@emotion/css": "^11.7.1", + "@formily/react": "2.2.26", + "@formily/shared": "2.2.26", + "@formily/antd": "2.2.26", + "@nocobase/server": "0.10.0-alpha.5", + "@nocobase/test": "0.10.0-alpha.5", + "react": "18.x", + "react-dom": "18.x", + "antd": "^4.24.8" } } diff --git a/packages/plugins/map/src/client/block/MapBlock.tsx b/packages/plugins/map/src/client/block/MapBlock.tsx index ddc6ff62f..80b396d0f 100644 --- a/packages/plugins/map/src/client/block/MapBlock.tsx +++ b/packages/plugins/map/src/client/block/MapBlock.tsx @@ -1,315 +1,11 @@ -import { CheckOutlined, EnvironmentOutlined, ExpandOutlined } from '@ant-design/icons'; -import { css } from '@emotion/css'; -import { RecursionField, Schema, useFieldSchema } from '@formily/react'; -import { - ActionContextProvider, - RecordProvider, - useCollection, - useCompile, - useFilterAPI, - useProps, -} from '@nocobase/client'; -import { useMemoizedFn } from 'ahooks'; -import { Button, Space } from 'antd'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import AMap, { AMapForwardedRefProps } from '../components/AMap'; -import { useMapTranslation } from '../locale'; - -const selectedImage = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAA/CAMAAAC7OkrPAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAEJQTFRFAAAA8Yti8Yti8Itj8Iti74tj8Itj8YtiKwADKwADKhw5Kh07KwADKwADKwADKwAD4odn1YBlKwADKwADKwADKwAD/5y7LQAAABZ0Uk5TAP/8/f/B/PYOHjY3CCozLP3XCSkvMhA05K4AAAC4SURBVHic7dXLDoIwEIXhwSsoLSrw/q8qbSAiMy3/oivj2czmS5omnVORdapVJJFKBSEL2mrrUurbpdXa5dTH5dXi9tTsGNtX0TFG1OT+7PdY2YdEGd0FuFmUwXWm5UAZbCTKYA3SUrXZQTHLHbUy2OlsMO0ultLsajLlbEWZMEUZ+ig5E6YKM2GqMBOmGKubW1D3ps6g1nnvA5uGa5Os85E9nmF2ebYkzeKhc9wre4V+GMeh317hDfXgCWigIGJbAAAAAElFTkSuQmCC'; -const defaultImage = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAA/CAYAAACM5Lr9AAAFkklEQVR42s3VaWxUVRjG8ddiEKPRxAQTCVSWLnZaWmjpTgulpYUBExP9Agou4IKAogUKAgItlNJCgUIXINFPfsFoDCjibkQRI0QIICAQWUpZugKtpcv0+DxyYqaTe2/vTLf58Etu7znnPf+0k44opQw5i5qsjIFs2APHoBZatFq+02vZeq94yyKs0dOjsAzOTCtuVtNKWtX0UpeaXqHU07tgt4ZnvuMa93Avz/CsniE2WIQV3iEaCDnQ8F/MTgZ4h2d4ljNgmZ4pFszDpmIRIuFP57YWDMcFu7uHMziLMyEKxJBl2MbbM6DZub2NQ3sUZ3K2vkMMGIdNKbi1ADqcZe1q2i7VKzibd8BCEE8GUQ3PQ4ez3KWcGNCbeAfv0neKm85hWRvqo+Hu1LJ2HuwTvAt3tkAciKbDIDO/7iE4l7X9rpqCA30pa3uLwt3nYRAIuIfV5mcWN6msnapf8G405IGQMGry+prB0DiloqPfwng3G3SLDltXnTt5S6PK3NnRr9jAFhDJyLsZAJcz8SGcjOr+xAa0XGGTpOfeSMrYUKcyKlx+gS1oSkbY9VXpm2+rjHKXX2ALm2TS2qq96duaVHp5u19gC5skbc3V05N23FWT8NIfsIVNkra6siGttE2llbX7h9JWhaZ6mfj+lfaJZW3Kn6DJJRNWXWqbgN+Y39jRqtDUKKkrL15PxfdjammrfyhpVmiqlNQVfx9KwX/cFJT6A7awSVLeu1CeUlinxu9o8QsphbUKTWUyfvn5Wcm511QSXvoDtrBJkpedewzaE/E58wdsYZMopSQ55+z+pKK6fo9KKqpXaDkAIgxLWnomK3H1JZVQ0tyv2ICW6XAvLHHJ6QD4K6GgRsVjQ3/g3WzQLffCKGHJqfHgisOXaNy2f/oU7+TdukGIUW5xJ0vic6+oWGzsS7yTd4NoncPiF594GC7FFlSrcVub+gTv4p36btE6h1Fc9nFn7IpzKmZrY5/gXbwTxI0O8xD77rGPYtZeVtE42Jt4B+8C8WAcNu6dPwZDzVh8PYzdcqdXcDbvgMdBPDHEOG7R0Xkxqy6oKAzpDZzNO0AMmIfFvH1kABwZk1epIotv9yjOxOyj+g4xYB4W/dbvlAAdozGsJ3Gmni0mzMPGLvyN6IMo/NojNt/qEZyFmR+CWLAIW3CYaDDUR+D/TTgGdwdncJaeKRbMw8bM/9XdosjlZ5VjU0O3cAZngXTBPCzqzV/cPQAXHeuqVBgu8IVj/TXFGXqWdME8LHLez55mRSw9qUI31fuEZzFjNogNFmFvHPQUACfC8IUbUlTvlbDcSsWzeobYYB42+vWfjDwbnn3c6zCewdnnQGwyD4t47UcjAXA2JO+qCi6ss4V7eUafFZuswn4wM8ex+LgKwqV2cC/OzAXxgnlY+KvfmxkIVaPyr6tRhbWWuId79RnxgnmYY+53VlaHLj2lRm6stRSSc0ph7xoQL1mEzfnWyhPQNrKgGgE1hrjGPTAExEvmYWGvfNOVPUErz6vhiDDCNez5GMQHVmFfd2Vq6MLD6smCGkNc4x4QH5iHPfXyV125H64NX1eFkOpO+I5reo/4wDws9KUDdhSPxFdNIGLc8R3XQHxkFfalHYnB8w6qYRuqO+E7roH4yDws5MX9dtwHlYF5lQi6qYjPfKfXxEcWYbO/sKtsBP50QxFFI/D/De8qQLrBPCwYizY9M2r+of/D+Mx3ID6zDJv1uaVwGfAIBDqGJsbhZ9eQ/BuK+OwYlhzPNe7hXh9Yhe0zwqBB4IAYouCZn1wIXH5GEZ/d1sjBMzzrBfOwoBf2GWFYBMS4C03JWRA087OaoBmf3uCz5zrP8Kx9lmF7jXiE2RbBs95ghB0MIo8/pS0OeJAzvOBLmKY//BAGURBN+jmMa3qPkLdh/wKOL8SpLbnYFgAAAABJRU5ErkJggg=='; +import React, { useState } from 'react'; +import { MapBlockComponent } from '../components'; +import { useCollection, useProps } from '@nocobase/client'; export const MapBlock = (props) => { - const { fieldNames, dataSource = [], fixedBlock, zoom, setSelectedRecordKeys } = useProps(props); - const { getField, getPrimaryKey } = useCollection(); + const { fieldNames } = useProps(props); + const { getField } = useCollection(); const field = getField(fieldNames?.field); - const [isMapInitialization, setIsMapInitialization] = useState(false); - const mapRef = useRef(); - const geometryUtils: AMap.IGeometryUtil = mapRef.current?.aMap?.GeometryUtil; - const [record, setRecord] = useState(); - const [selectingMode, setSelecting] = useState(''); - const { t } = useMapTranslation(); - const compile = useCompile(); - const { isConnected, doFilter } = useFilterAPI(); - const [, setPrevSelected] = useState(null); - const selectingModeRef = useRef(selectingMode); - selectingModeRef.current = selectingMode; - - const setOverlayOptions = (overlay: AMap.Polygon | AMap.Marker, state?: boolean) => { - const extData = overlay.getExtData(); - const selected = typeof state === 'undefined' ? extData.selected : !state; - extData.selected = !selected; - if ('setIcon' in overlay) { - overlay.setIcon( - new mapRef.current.aMap.Icon({ - imageSize: [19, 32], - image: selected ? defaultImage : selectedImage, - } as AMap.IconOpts), - ); - } - (overlay as AMap.Polygon).setOptions({ - extData, - ...(selected - ? { strokeColor: '#4e9bff', fillColor: '#4e9bff' } - : { strokeColor: '#F18b62', fillColor: '#F18b62' }), - }); - }; - - const removeSelection = () => { - mapRef.current.mouseTool().close(true); - mapRef.current.editor().setTarget(null); - mapRef.current.editor().close(); - }; - - // selection - useEffect(() => { - if (selectingMode !== 'selection') { - return; - } - if (!mapRef.current.editor()) { - mapRef.current.createEditor('polygon'); - mapRef.current.createMouseTool('polygon'); - } else { - mapRef.current.executeMouseTool('polygon'); - } - return () => { - removeSelection(); - }; - }, [selectingMode]); - - useEffect(() => { - if (selectingMode) { - return () => { - if (!selectingModeRef.current) { - mapRef.current.map.getAllOverlays().forEach((o) => { - setOverlayOptions(o, false); - }); - } - }; - } - }, [selectingMode]); - - const onSelectingComplete = useMemoizedFn(() => { - const selectingOverlay = mapRef.current.editor().getTarget(); - const overlays = mapRef.current.map.getAllOverlays(); - const selectedOverlays = overlays.filter((o) => { - if (o === selectingOverlay || o.getExtData().id === undefined) return; - if ('getPosition' in o) { - return geometryUtils.isPointInRing(o.getPosition(), selectingOverlay.getPath() as any); - } - return geometryUtils.doesRingRingIntersect(o.getPath(), selectingOverlay.getPath() as any); - }); - const ids = selectedOverlays.map((o) => { - setOverlayOptions(o, true); - return o.getExtData().id; - }); - setSelectedRecordKeys((lastIds) => ids.concat(lastIds)); - selectingOverlay.remove(); - mapRef.current.editor().close(); - }); - - useEffect(() => { - if (!field || !mapRef.current) return; - const overlays = dataSource - .map((item) => { - const data = item[fieldNames?.field]; - if (!data) return; - const overlay = mapRef.current.setOverlay(field.type, data, { - strokeColor: '#4e9bff', - fillColor: '#4e9bff', - cursor: 'pointer', - label: { - direction: 'bottom', - offset: [0, 5], - content: fieldNames?.marker ? compile(item[fieldNames.marker]) : undefined, - }, - extData: { - id: item[getPrimaryKey()], - }, - }); - return overlay; - }) - .filter(Boolean); - mapRef.current.map?.setFitView(overlays); - - const events = overlays.map((o: AMap.Marker) => { - const onClick = (e) => { - const overlay: AMap.Polygon | AMap.Marker = e.target; - const extData = overlay.getExtData(); - if (!extData) return; - if (selectingModeRef.current) { - if (selectingModeRef.current === 'click') { - setSelectedRecordKeys((keys) => - extData.selected ? keys.filter((key) => key !== extData.id) : [...keys, extData.id], - ); - setOverlayOptions(overlay); - } - return; - } - const data = dataSource?.find((item) => { - return extData.id === item[getPrimaryKey()]; - }); - - // 筛选区块模式 - if (isConnected) { - setPrevSelected((prev) => { - prev && clearSelected(prev); - if (prev === o) { - clearSelected(o); - - // 删除过滤参数 - doFilter(null); - return null; - } else { - selectMarker(o); - doFilter(data[getPrimaryKey()], (target) => target.field || getPrimaryKey(), '$eq'); - } - return o; - }); - - return; - } - - if (data) { - setRecord(data); - } - }; - o.on('click', onClick); - return () => o.off('click', onClick); - }); - - return () => { - overlays.forEach((ov) => { - ov.remove(); - }); - events.forEach((e) => e()); - }; - }, [dataSource, isMapInitialization, fieldNames, field.type, isConnected]); - - useEffect(() => { - setTimeout(() => { - setSelectedRecordKeys([]); - }); - }, [dataSource]); - - const mapRefCallback = (instance: AMapForwardedRefProps) => { - mapRef.current = instance; - setIsMapInitialization(!!instance?.aMap); - }; - - return ( -
-
- - - - {selectingMode === 'selection' ? ( - - ) : null} - -
- - -
- ); + const fieldComponentProps = field?.uiSchema?.['x-component-props']; + return ; }; - -const MapBlockDrawer = (props) => { - const { setVisible, record } = props; - const fieldSchema = useFieldSchema(); - const schema: Schema = useMemo( - () => - fieldSchema.reduceProperties((buf, current) => { - if (current.name === 'drawer') { - return current; - } - return buf; - }, null), - [fieldSchema], - ); - - return ( - schema && ( - - - - - - ) - ); -}; - -function clearSelected(marker: AMap.Marker | AMap.Polygon | AMap.Polyline | AMap.Circle) { - if ((marker as AMap.Marker).dom) { - (marker as AMap.Marker).dom.style.filter = 'none'; - - // AMap.Polygon | AMap.Polyline | AMap.Circle 都有 setOptions 方法 - } else if ((marker as AMap.Polygon).setOptions) { - (marker as AMap.Polygon).setOptions({ - strokeColor: '#4e9bff', - fillColor: '#4e9bff', - }); - } -} - -function selectMarker(marker: AMap.Marker | AMap.Polygon | AMap.Polyline | AMap.Circle) { - if ((marker as AMap.Marker).dom) { - (marker as AMap.Marker).dom.style.filter = 'brightness(1.2) contrast(1.2) hue-rotate(180deg)'; - - // AMap.Polygon | AMap.Polyline | AMap.Circle 都有 setOptions 方法 - } else if ((marker as AMap.Polygon).setOptions) { - (marker as AMap.Polygon).setOptions({ - strokeColor: '#F18b62', - fillColor: '#F18b62', - }); - } -} diff --git a/packages/plugins/map/src/client/block/MapBlockInitializer.tsx b/packages/plugins/map/src/client/block/MapBlockInitializer.tsx index 0153aebdd..9903c25f5 100644 --- a/packages/plugins/map/src/client/block/MapBlockInitializer.tsx +++ b/packages/plugins/map/src/client/block/MapBlockInitializer.tsx @@ -1,7 +1,7 @@ import { TableOutlined } from '@ant-design/icons'; -import { DataBlockInitializer, SchemaComponent, SchemaComponentOptions, useCollectionManager } from '@nocobase/client'; -import { SchemaOptionsContext } from '@formily/react'; import { FormDialog, FormLayout } from '@formily/antd'; +import { SchemaOptionsContext } from '@formily/react'; +import { DataBlockInitializer, SchemaComponent, SchemaComponentOptions, useCollectionManager } from '@nocobase/client'; import React, { useContext } from 'react'; import { useMapTranslation } from '../locale'; import { createMapBlockSchema } from './utils'; @@ -9,6 +9,7 @@ import { createMapBlockSchema } from './utils'; export const MapBlockInitializer = (props) => { const { insert } = props; const options = useContext(SchemaOptionsContext); + console.log('🚀 ~ file: MapBlockInitializer.tsx:12 ~ MapBlockInitializer ~ options:', options); const { getCollectionFieldsOptions } = useCollectionManager(); const { t } = useMapTranslation(); diff --git a/packages/plugins/map/src/client/components/AMap/Block.tsx b/packages/plugins/map/src/client/components/AMap/Block.tsx new file mode 100644 index 000000000..f7d631ba7 --- /dev/null +++ b/packages/plugins/map/src/client/components/AMap/Block.tsx @@ -0,0 +1,314 @@ +import { CheckOutlined, EnvironmentOutlined, ExpandOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { RecursionField, Schema, useFieldSchema } from '@formily/react'; +import { + ActionContextProvider, + RecordProvider, + useCollection, + useCompile, + useFilterAPI, + useProps, +} from '@nocobase/client'; +import { useMemoizedFn } from 'ahooks'; +import { Button, Space } from 'antd'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { defaultImage, selectedImage } from '../../constants'; +import { useMapTranslation } from '../../locale'; +import { AMapComponent, AMapForwardedRefProps } from './Map'; + +export const AMapBlock = (props) => { + const { fieldNames, dataSource = [], fixedBlock, zoom, setSelectedRecordKeys } = useProps(props); + const { getField, getPrimaryKey } = useCollection(); + const field = getField(fieldNames?.field); + const [isMapInitialization, setIsMapInitialization] = useState(false); + const mapRef = useRef(); + const geometryUtils: AMap.IGeometryUtil = mapRef.current?.aMap?.GeometryUtil; + const [record, setRecord] = useState(); + const [selectingMode, setSelecting] = useState(''); + const { t } = useMapTranslation(); + const compile = useCompile(); + const { isConnected, doFilter } = useFilterAPI(); + const [, setPrevSelected] = useState(null); + const selectingModeRef = useRef(selectingMode); + selectingModeRef.current = selectingMode; + + const setOverlayOptions = (overlay: AMap.Polygon | AMap.Marker, state?: boolean) => { + const extData = overlay.getExtData(); + const selected = typeof state === 'undefined' ? extData.selected : !state; + extData.selected = !selected; + if ('setIcon' in overlay) { + overlay.setIcon( + new mapRef.current.aMap.Icon({ + imageSize: [19, 32], + image: selected ? defaultImage : selectedImage, + } as AMap.IconOpts), + ); + } + (overlay as AMap.Polygon).setOptions({ + extData, + ...(selected + ? { strokeColor: '#4e9bff', fillColor: '#4e9bff' } + : { strokeColor: '#F18b62', fillColor: '#F18b62' }), + }); + }; + + const removeSelection = () => { + if (!mapRef.current) return; + mapRef.current.mouseTool().close(true); + mapRef.current.editor().setTarget(null); + mapRef.current.editor().close(); + }; + + // selection + useEffect(() => { + if (selectingMode !== 'selection') { + return; + } + if (!mapRef.current.editor()) { + mapRef.current.createEditor('polygon'); + mapRef.current.createMouseTool('polygon'); + } else { + mapRef.current.executeMouseTool('polygon'); + } + return () => { + removeSelection(); + }; + }, [selectingMode]); + + useEffect(() => { + if (selectingMode) { + return () => { + if (!selectingModeRef.current) { + mapRef.current.map.getAllOverlays().forEach((o) => { + setOverlayOptions(o, false); + }); + } + }; + } + }, [selectingMode]); + + const onSelectingComplete = useMemoizedFn(() => { + const selectingOverlay = mapRef.current.editor().getTarget(); + const overlays = mapRef.current.map.getAllOverlays(); + const selectedOverlays = overlays.filter((o) => { + if (o === selectingOverlay || o.getExtData().id === undefined) return; + if ('getPosition' in o) { + return geometryUtils.isPointInRing(o.getPosition(), selectingOverlay.getPath() as any); + } + return geometryUtils.doesRingRingIntersect(o.getPath(), selectingOverlay.getPath() as any); + }); + const ids = selectedOverlays.map((o) => { + setOverlayOptions(o, true); + return o.getExtData().id; + }); + setSelectedRecordKeys((lastIds) => ids.concat(lastIds)); + selectingOverlay.remove(); + mapRef.current.editor().close(); + }); + + useEffect(() => { + if (!field || !mapRef.current) return; + const overlays = dataSource + .map((item) => { + const data = item[fieldNames?.field]; + if (!data) return; + const overlay = mapRef.current.setOverlay(field.type, data, { + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + cursor: 'pointer', + label: { + direction: 'bottom', + offset: [0, 5], + content: fieldNames?.marker ? compile(item[fieldNames.marker]) : undefined, + }, + extData: { + id: item[getPrimaryKey()], + }, + }); + return overlay; + }) + .filter(Boolean); + mapRef.current.map?.setFitView(overlays); + + const events = overlays.map((o: AMap.Marker) => { + const onClick = (e) => { + const overlay: AMap.Polygon | AMap.Marker = e.target; + const extData = overlay.getExtData(); + if (!extData) return; + if (selectingModeRef.current) { + if (selectingModeRef.current === 'click') { + setSelectedRecordKeys((keys) => + extData.selected ? keys.filter((key) => key !== extData.id) : [...keys, extData.id], + ); + setOverlayOptions(overlay); + } + return; + } + const data = dataSource?.find((item) => { + return extData.id === item[getPrimaryKey()]; + }); + + // 筛选区块模式 + if (isConnected) { + setPrevSelected((prev) => { + prev && clearSelected(prev); + if (prev === o) { + clearSelected(o); + + // 删除过滤参数 + doFilter(null); + return null; + } else { + selectMarker(o); + doFilter(data[getPrimaryKey()], (target) => target.field || getPrimaryKey(), '$eq'); + } + return o; + }); + + return; + } + + if (data) { + setRecord(data); + } + }; + o.on('click', onClick); + return () => o.off('click', onClick); + }); + + return () => { + overlays.forEach((ov) => { + ov.remove(); + }); + events.forEach((e) => e()); + }; + }, [dataSource, isMapInitialization, fieldNames, field.type, isConnected]); + + useEffect(() => { + setTimeout(() => { + setSelectedRecordKeys([]); + }); + }, [dataSource]); + + const mapRefCallback = (instance: AMapForwardedRefProps) => { + mapRef.current = instance; + setIsMapInitialization(!!instance?.map && !instance.errMessage); + }; + + return ( +
+
+ {isMapInitialization && !mapRef.current.errMessage ? ( + + + + {selectingMode === 'selection' ? ( + + ) : null} + + ) : null} +
+ + +
+ ); +}; + +const MapBlockDrawer = (props) => { + const { setVisible, record } = props; + const fieldSchema = useFieldSchema(); + const schema: Schema = useMemo( + () => + fieldSchema.reduceProperties((buf, current) => { + if (current.name === 'drawer') { + return current; + } + return buf; + }, null), + [fieldSchema], + ); + + return ( + schema && ( + + + + + + ) + ); +}; + +function clearSelected(marker: AMap.Marker | AMap.Polygon | AMap.Polyline | AMap.Circle) { + if ((marker as AMap.Marker).dom) { + (marker as AMap.Marker).dom.style.filter = 'none'; + + // AMap.Polygon | AMap.Polyline | AMap.Circle 都有 setOptions 方法 + } else if ((marker as AMap.Polygon).setOptions) { + (marker as AMap.Polygon).setOptions({ + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + }); + } +} + +function selectMarker(marker: AMap.Marker | AMap.Polygon | AMap.Polyline | AMap.Circle) { + if ((marker as AMap.Marker).dom) { + (marker as AMap.Marker).dom.style.filter = 'brightness(1.2) contrast(1.2) hue-rotate(180deg)'; + + // AMap.Polygon | AMap.Polyline | AMap.Circle 都有 setOptions 方法 + } else if ((marker as AMap.Polygon).setOptions) { + (marker as AMap.Polygon).setOptions({ + strokeColor: '#F18b62', + fillColor: '#F18b62', + }); + } +} diff --git a/packages/plugins/map/src/client/components/AMap.tsx b/packages/plugins/map/src/client/components/AMap/Map.tsx similarity index 95% rename from packages/plugins/map/src/client/components/AMap.tsx rename to packages/plugins/map/src/client/components/AMap/Map.tsx index 8416e16d3..299195e87 100644 --- a/packages/plugins/map/src/client/components/AMap.tsx +++ b/packages/plugins/map/src/client/components/AMap/Map.tsx @@ -6,13 +6,12 @@ import { useFieldSchema } from '@formily/react'; import { useCollection } from '@nocobase/client'; import { useMemoizedFn } from 'ahooks'; import { Alert, Button, Modal, Spin } from 'antd'; -import React, { useEffect, useCallback, useRef, useState, useMemo, useImperativeHandle } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useMapConfiguration } from '../hooks'; -import { useMapTranslation } from '../locale'; -import Search from './Search'; - -export type MapEditorType = 'point' | 'polygon' | 'lineString' | 'circle'; +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useMapConfiguration } from '../../hooks'; +import { useMapTranslation } from '../../locale'; +import { MapEditorType } from '../../types'; +import { Search } from './Search'; export interface AMapComponentProps { value?: any; @@ -80,9 +79,10 @@ export interface AMapForwardedRefProps { close: (clear?: boolean) => void; }; overlay: AMap.Polygon; + errMessage?: string; } -const AMapComponent = React.forwardRef((props, ref) => { +export const AMapComponent = React.forwardRef((props, ref) => { const { accessKey, securityJsCode } = useMapConfiguration(props.mapType) || {}; const { value, onChange, block = false, readonly, disabled = block, zoom = 13, overlayCommonOptions } = props; const { t } = useMapTranslation(); @@ -358,13 +358,14 @@ const AMapComponent = React.forwardRef mouseTool.current, editor: () => editor.current, + errMessage, })); if (!accessKey || errMessage) { return ( navigate('/admin/settings/map-configuration/configuration')}> + } @@ -458,5 +459,3 @@ const AMapComponent = React.forwardRef ); }); - -export default AMapComponent; diff --git a/packages/plugins/map/src/client/components/Search.tsx b/packages/plugins/map/src/client/components/AMap/Search.tsx similarity index 94% rename from packages/plugins/map/src/client/components/Search.tsx rename to packages/plugins/map/src/client/components/AMap/Search.tsx index 15b8bbeff..b0f193883 100644 --- a/packages/plugins/map/src/client/components/Search.tsx +++ b/packages/plugins/map/src/client/components/AMap/Search.tsx @@ -2,14 +2,14 @@ import { css } from '@emotion/css'; import { useDebounceFn } from 'ahooks'; import { message, Select } from 'antd'; import React, { useEffect, useRef, useState } from 'react'; -import { useMapTranslation } from '../locale'; +import { useMapTranslation } from '../../locale'; interface SearchProps { aMap: any; toCenter: (p: any) => void; } -const Search = (props: SearchProps) => { +export const Search = (props: SearchProps) => { const { aMap, toCenter } = props; const { t } = useMapTranslation(); const placeSearch = useRef(); @@ -90,5 +90,3 @@ const Search = (props: SearchProps) => { ); }; - -export default Search; diff --git a/packages/plugins/map/src/client/components/AMap/index.ts b/packages/plugins/map/src/client/components/AMap/index.ts new file mode 100644 index 000000000..8440c742b --- /dev/null +++ b/packages/plugins/map/src/client/components/AMap/index.ts @@ -0,0 +1,2 @@ +export * from './Block'; +export * from './Map'; diff --git a/packages/plugins/map/src/client/components/Configuration.tsx b/packages/plugins/map/src/client/components/Configuration.tsx index de4dec35e..0db221481 100644 --- a/packages/plugins/map/src/client/components/Configuration.tsx +++ b/packages/plugins/map/src/client/components/Configuration.tsx @@ -1,12 +1,16 @@ import { useAPIClient, useCompile } from '@nocobase/client'; import { useBoolean } from 'ahooks'; -import { Button, Card, Form, Input, Tabs, message } from 'antd'; +import { Button, Card, Form, Input, message, Tabs } from 'antd'; import React, { useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import { MapTypes } from '../constants'; -import { MapConfigurationResourceKey, getSSKey, useMapConfiguration } from '../hooks'; +import { getSSKey, MapConfigurationResourceKey, useMapConfiguration } from '../hooks'; import { useMapTranslation } from '../locale'; -const AMapConfiguration = ({ type }) => { +interface BaseConfigurationProps { + type: 'amap' | 'google'; +} +const BaseConfiguration: React.FC = ({ type, children }) => { const { t } = useMapTranslation(); const [isDisabled, disableAction] = useBoolean(false); const apiClient = useAPIClient(); @@ -31,6 +35,7 @@ const AMapConfiguration = ({ type }) => { }) .then((res) => { sessionStorage.removeItem(getSSKey(type)); + disableAction.toggle(); message.success(t('Saved successfully')); }) .catch((err) => { @@ -38,20 +43,15 @@ const AMapConfiguration = ({ type }) => { }); }; return ( -
- - - - - - + + {children} {isDisabled ? ( - ) : ( - @@ -60,9 +60,34 @@ const AMapConfiguration = ({ type }) => { ); }; +const AMapConfiguration = () => { + const { t } = useMapTranslation(); + return ( + + + + + + + + + ); +}; + +const GoogleMapConfiguration = () => { + const { t } = useMapTranslation(); + return ( + + + + + + ); +}; + const components = { amap: AMapConfiguration, - google: () =>
Coming soon
, + google: GoogleMapConfiguration, }; const tabList = MapTypes.map((item) => { @@ -72,22 +97,21 @@ const tabList = MapTypes.map((item) => { }; }); -const Configuration = () => { +export const Configuration = () => { const compile = useCompile(); + const location = useLocation(); + const search = new URLSearchParams(location.search); return ( - { - return { - key: tab.value, - label: compile(tab.label), - children: , - }; + + {tabList.map((tab) => { + return ( + + + + ); })} - /> + ); }; - -export default Configuration; diff --git a/packages/plugins/map/src/client/components/GoogleMaps/Block.tsx b/packages/plugins/map/src/client/components/GoogleMaps/Block.tsx new file mode 100644 index 000000000..15cd14e3a --- /dev/null +++ b/packages/plugins/map/src/client/components/GoogleMaps/Block.tsx @@ -0,0 +1,341 @@ +import { CheckOutlined, EnvironmentOutlined, ExpandOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { RecursionField, Schema, useFieldSchema } from '@formily/react'; +import { + ActionContextProvider, + RecordProvider, + useCollection, + useCompile, + useFilterAPI, + useProps, +} from '@nocobase/client'; +import { useMemoizedFn } from 'ahooks'; +import { Button, Space } from 'antd'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { defaultImage, selectedImage } from '../../constants'; +import { useMapTranslation } from '../../locale'; +import { GoogleMapForwardedRefProps, GoogleMapsComponent, OverlayOptions } from './Map'; +import { getIcon } from './utils'; + +const OVERLAY_KEY = 'google-maps-overlay-id'; +const OVERLAY_SELECtED = 'google-maps-overlay-selected'; + +const labelClass = css` + margin-top: 6px; + padding: 2px 4px; + background: #fff; + border: 1px solid #0000f5; +`; + +export const GoogleMapsBlock = (props) => { + const { fieldNames, dataSource = [], fixedBlock, zoom, setSelectedRecordKeys } = useProps(props); + const { getField, getPrimaryKey } = useCollection(); + const { marker: markerName, field: fieldName } = fieldNames || { + marker: 'id', + field: 'id', + }; + const field = getField(fieldName); + const [isMapInitialization, setIsMapInitialization] = useState(false); + const mapRef = useRef(); + const [record, setRecord] = useState(); + const [selectingMode, setSelecting] = useState(''); + const { t } = useMapTranslation(); + const compile = useCompile(); + const { isConnected, doFilter } = useFilterAPI(); + const [, setPrevSelected] = useState(null); + const selectingModeRef = useRef(selectingMode); + const selectionOverlayRef = useRef(); + const overlaysRef = useRef([]); + selectingModeRef.current = selectingMode; + + const setOverlayOptions = (overlay: google.maps.MVCObject, state?: boolean) => { + const selected = typeof state !== 'undefined' ? !state : overlay.get(OVERLAY_SELECtED); + overlay.set(OVERLAY_SELECtED, !selected); + (overlay as google.maps.Marker).setOptions({ + ...(selected + ? { + icon: getIcon(defaultImage), + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + } + : { + icon: getIcon(selectedImage), + strokeColor: '#F18b62', + fillColor: '#F18b62', + }), + } as OverlayOptions); + }; + + // selection + useEffect(() => { + if (selectingMode !== 'selection') { + return; + } + if (!mapRef.current.drawingManager) { + mapRef.current.drawingManager = mapRef.current.createDraw(true, { + editable: true, + draggable: true, + }); + } + const listenerSet = new Set<() => void>(); + mapRef.current.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON); + mapRef.current.drawingManager.addListener('overlaycomplete', (event) => { + const polygon = event.overlay as google.maps.Polygon; + mapRef.current.drawingManager.setDrawingMode(null); + selectionOverlayRef.current = polygon; + const path = polygon.getPath(); + ['insert_at', 'remove_at', 'set_at'].forEach((key) => { + listenerSet.add(path.addListener(key, () => {}).remove); + }); + }); + return () => { + listenerSet.forEach((i) => { + i(); + }); + if (!mapRef.current) return; + selectionOverlayRef.current?.unbindAll(); + selectionOverlayRef.current?.setMap(null); + selectionOverlayRef.current = null; + mapRef.current.drawingManager.setDrawingMode(null); + mapRef.current.drawingManager.unbindAll(); + }; + }, [selectingMode]); + + useEffect(() => { + if (selectingMode) { + return () => { + if (!selectingModeRef.current) { + overlaysRef.current.forEach((o) => { + setOverlayOptions(o, false); + }); + } + }; + } + }, [selectingMode]); + + const onSelectingComplete = useMemoizedFn(() => { + const overlay = selectionOverlayRef.current; + const overlays = overlaysRef.current; + const poly = google.maps.geometry.poly; + const selectedOverlays = overlays.filter((o) => { + if (o === overlay || o.get(OVERLAY_KEY) === undefined) return; + if (o instanceof google.maps.Marker) { + return poly.containsLocation(o.getPosition(), overlay); + } else if (o instanceof google.maps.Circle) { + return poly.containsLocation(o.getCenter(), overlay); + } else { + return (o as google.maps.Polygon) + .getPath() + .getArray() + .some((position) => { + return poly.containsLocation(position, overlay); + }); + } + }); + const ids = selectedOverlays.map((o) => { + setOverlayOptions(o, true); + return o.get(OVERLAY_KEY); + }); + setSelectedRecordKeys((lastIds) => ids.concat(lastIds)); + overlay.unbindAll(); + overlay.setMap(null); + mapRef.current.drawingManager.setDrawingMode(google.maps.drawing.OverlayType.POLYGON); + }); + + useEffect(() => { + if (!field || !mapRef.current?.map) return; + const overlays: google.maps.Polygon[] = dataSource + .map((item) => { + const data = item[fieldNames?.field]; + if (!data) return; + const overlay = mapRef.current.setOverlay(field.type, data, { + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + cursor: 'pointer', + label: { + className: labelClass, + fontFamily: 'inherit', + fontSize: '13px', + color: '#333', + text: fieldNames?.marker ? compile(item[markerName]) : undefined, + } as google.maps.MarkerLabel, + }); + overlay.set(OVERLAY_KEY, item[getPrimaryKey()]); + return overlay; + }) + .filter(Boolean); + + overlaysRef.current = overlays; + mapRef.current.setFitView(overlays); + + const events = overlays.map((o: google.maps.MVCObject) => { + const onClick = (event) => { + const overlay = o as google.maps.Polygon; + const id = overlay.get(OVERLAY_KEY); + if (!id) return; + + const data = dataSource?.find((item) => { + return id === item[getPrimaryKey()]; + }); + + // 筛选区块模式 + if (isConnected) { + setPrevSelected((prev) => { + prev && clearSelected(overlay); + if (prev === o) { + clearSelected(overlay); + + // 删除过滤参数 + doFilter(null); + return null; + } else { + selectMarker(overlay); + doFilter(data[getPrimaryKey()], (target) => target.field || getPrimaryKey(), '$eq'); + } + return overlay; + }); + + return; + } + + if (data) { + setRecord(data); + } + }; + o.addListener('click', onClick); + return () => o.unbindAll(); + }); + + return () => { + overlays.forEach((ov) => { + ov.setMap(null); + ov.unbindAll(); + }); + events.forEach((e) => e()); + }; + }, [dataSource, isMapInitialization, markerName, field.type, isConnected]); + + useEffect(() => { + setTimeout(() => { + setSelectedRecordKeys([]); + }); + }, [dataSource]); + + const mapRefCallback = (instance: GoogleMapForwardedRefProps) => { + mapRef.current = instance; + setIsMapInitialization(!!instance?.map && !instance.errMessage); + }; + + return ( +
+ {isMapInitialization && ( + <> +
+ + + + {selectingMode === 'selection' ? ( + + ) : null} + +
+ + + )} + +
+ ); +}; + +const MapBlockDrawer = (props) => { + const { setVisible, record } = props; + const fieldSchema = useFieldSchema(); + const schema: Schema = useMemo( + () => + fieldSchema.reduceProperties((buf, current) => { + if (current.name === 'drawer') { + return current; + } + return buf; + }, null), + [fieldSchema], + ); + + return ( + schema && ( + + + + + + ) + ); +}; + +function clearSelected(target: google.maps.Polygon) { + if (target instanceof google.maps.Marker) { + return target.setIcon(getIcon(defaultImage)); + } + target.setOptions({ + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + }); +} + +function selectMarker(target: google.maps.Polygon) { + if (target instanceof google.maps.Marker) { + return target.setIcon(getIcon(selectedImage)); + } + target.setOptions({ + strokeColor: '#F18b62', + fillColor: '#F18b62', + }); +} diff --git a/packages/plugins/map/src/client/components/GoogleMaps/Map.tsx b/packages/plugins/map/src/client/components/GoogleMaps/Map.tsx new file mode 100644 index 000000000..403a967d6 --- /dev/null +++ b/packages/plugins/map/src/client/components/GoogleMaps/Map.tsx @@ -0,0 +1,470 @@ +import { SyncOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { useFieldSchema } from '@formily/react'; +import { Loader } from '@googlemaps/js-api-loader'; +import { useAPIClient, useCollection } from '@nocobase/client'; +import { useMemoizedFn } from 'ahooks'; +import { Alert, Button, Modal, Spin } from 'antd'; +import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { defaultImage } from '../../constants'; +import { useMapConfiguration } from '../../hooks'; +import { useMapTranslation } from '../../locale'; +import { MapEditorType } from '../../types'; +import { Search } from './Search'; +import { getCurrentPosition, getIcon } from './utils'; + +export type OverlayOptions = google.maps.PolygonOptions & google.maps.MarkerOptions & google.maps.PolylineOptions; + +export const getDrawingMode = (type: MapEditorType) => { + if (type === 'point') { + return 'marker'; + } else if (type === 'lineString') { + return 'polyline'; + } + return type; +}; + +const methodMapping = { + point: { + propertyKey: 'position', + overlay: 'Marker', + }, + polygon: { + propertyKey: 'paths', + overlay: 'Polygon', + }, + lineString: { + propertyKey: 'path', + overlay: 'Polyline', + }, + circle: { + transformOptions(value) { + return { + center: new google.maps.LatLng(value[1], value[0]), + radius: value[2], + } as google.maps.CircleOptions; + }, + overlay: 'Circle', + }, +}; + +export interface GoogleMapsComponentProps { + value?: any; + onChange?: (value: number[]) => void; + disabled?: boolean; + mapType: string; + /** + * only ReadPretty + */ + readonly: string; + zoom: number; + type: MapEditorType; + style?: React.CSSProperties; + overlayCommonOptions?: OverlayOptions; + block?: boolean; +} + +export interface GoogleMapForwardedRefProps { + setOverlay: (t: MapEditorType, v: any, o?: OverlayOptions) => google.maps.MVCObject; + getOverlay: (t: MapEditorType, v: any, o?: OverlayOptions) => google.maps.MVCObject; + setFitView: (overlays: google.maps.MVCObject[]) => void; + createDraw: (onlyCreate?: boolean, additionalOptions?: OverlayOptions) => any; + map: google.maps.Map; + overlay: google.maps.MVCObject; + drawingManager: google.maps.drawing.DrawingManager; + errMessage?: string; +} + +export const GoogleMapsComponent = React.forwardRef( + (props, ref) => { + const { value, onChange, block = false, readonly, disabled = block, zoom = 13, overlayCommonOptions } = props; + const { accessKey } = useMapConfiguration(props.mapType) || {}; + const { t } = useMapTranslation(); + const { getField } = useCollection(); + const fieldSchema = useFieldSchema(); + const drawingManagerRef = useRef(); + const map = useRef(); + const overlayRef = useRef(); + const [needUpdateFlag, forceUpdate] = useState([]); + const [errMessage, setErrMessage] = useState(''); + const api = useAPIClient(); + + const type = useMemo(() => { + if (props.type) return props.type; + const collectionField = getField(fieldSchema?.name); + return collectionField?.interface; + }, [props?.type, fieldSchema?.name]); + + const drawingMode = useRef(getDrawingMode(type) as google.maps.drawing.OverlayType); + + const [commonOptions] = useState({ + strokeWeight: 5, + strokeColor: '#4e9bff', + fillColor: '#4e9bff', + strokeOpacity: 1, + editable: !disabled, + draggable: !disabled, + ...overlayCommonOptions, + }); + + const navigate = useNavigate(); + const mapContainerRef = useRef(); + const cleanupOverlayListenersRef = useRef void>>(new Set()); + + const onAndOffListenOverlay = useMemoizedFn((target: typeof overlayRef.current) => { + cleanupOverlayListenersRef.current.forEach((cb) => { + cleanupOverlayListenersRef.current.delete(cb); + }); + + if ('getPath' in target) { + const mvcArray = target.getPath(); + ['insert_at', 'remove_at', 'set_at'].forEach((event) => { + cleanupOverlayListenersRef.current.add( + mvcArray.addListener(event, () => { + onMapChange(target, true); + }).remove, + ); + }); + } else if (target instanceof google.maps.Circle) { + ['center_changed', 'radius_changed'].forEach((event) => { + cleanupOverlayListenersRef.current.add( + target.addListener(event, () => { + onMapChange(target, true); + }).remove, + ); + }); + } + }); + + const toRemoveOverlay = useMemoizedFn(() => { + if (overlayRef.current) { + overlayRef.current.unbindAll(); + overlayRef.current.setMap(null); + } + if (type !== 'point') { + drawingManagerRef.current?.setDrawingMode(null); + } + }); + + const toCenter = useMemoizedFn((position) => { + if (map.current) { + map.current.setCenter(position); + map.current.setZoom(zoom); + } + }); + + const setupOverlay = useMemoizedFn((nextOverlay: typeof overlayRef.current) => { + toRemoveOverlay(); + onAndOffListenOverlay(nextOverlay); + overlayRef.current = nextOverlay; + }); + + const setFitView = useMemoizedFn((overlays: google.maps.MVCObject[]) => { + const bounds = new google.maps.LatLngBounds(); + + overlays.forEach((overlay) => { + if (overlay instanceof google.maps.Marker) { + bounds.extend(overlay.getPosition()); + } else if (overlay instanceof google.maps.Polyline || overlay instanceof google.maps.Polygon) { + const path = overlay.getPath(); + for (let i = 0; i < path.getLength(); i++) { + bounds.extend(path.getAt(i)); + } + } else if (overlay instanceof google.maps.Circle) { + bounds.union(overlay.getBounds()); + } + }); + + map.current.setCenter(bounds.getCenter()); + }); + + const onFocusOverlay = () => { + if (overlayRef.current) { + setFitView([overlayRef.current]); + } + }; + + const onMapChange = useMemoizedFn((target: typeof overlayRef.current, onlyChange = false) => { + let nextValue = null; + + if (type === 'point') { + const { lat, lng } = (target as google.maps.Marker).getPosition(); + nextValue = [lng(), lat()]; + } else if (type === 'polygon' || type === 'lineString') { + nextValue = (target as google.maps.Polyline) + .getPath() + .getArray() + .map((item) => [item.lng(), item.lat()]); + if (nextValue.length < 2) { + return; + } + } else if (type === 'circle') { + const center = (target as google.maps.Circle).getCenter(); + const radius = (target as google.maps.Circle).getRadius(); + nextValue = [center.lng(), center.lat(), radius]; + } + + if (!onlyChange) { + setupOverlay(target); + } + onChange?.(nextValue); + }); + + const createDraw = useMemoizedFn((onlyCreate = false, additionalOptions?: OverlayOptions) => { + const currentOptions = { + ...commonOptions, + ...additionalOptions, + map: map.current, + }; + drawingManagerRef.current = new google.maps.drawing.DrawingManager({ + drawingMode: drawingMode.current, + drawingControl: false, + markerOptions: { ...currentOptions, icon: getIcon(defaultImage) }, + polygonOptions: currentOptions, + polylineOptions: currentOptions, + circleOptions: currentOptions, + map: map.current, + }); + + if (!onlyCreate) { + drawingManagerRef.current.addListener('overlaycomplete', (event: { type: string; overlay: unknown }) => { + const overlay = event.overlay as google.maps.Marker; + onMapChange(overlay); + }); + } + return drawingManagerRef.current; + }); + + const getOverlay = useMemoizedFn((t = type, v = value, o?: OverlayOptions) => { + const mapping = methodMapping[t]; + if (!mapping) { + return; + } + const options = { ...commonOptions, icon: getIcon(defaultImage), ...o }; + + if ('transformOptions' in mapping) { + Object.assign(options, mapping.transformOptions(v)); + } else if ('propertyKey' in mapping) { + options[mapping.propertyKey] = Array.isArray(v[0]) + ? v.map((item) => { + return new google.maps.LatLng(item[1], item[0]); + }) + : new google.maps.LatLng(v[1], v[0]); + } + + const overlay = new google.maps[mapping.overlay](options); + return overlay; + }); + + const setOverlay = useMemoizedFn((t = type, v = value, o?: OverlayOptions) => { + if (!map.current) return; + const nextOverlay = getOverlay(t, v, { + ...o, + map: map.current, + }) as google.maps.Polyline; + return nextOverlay; + }); + + // edit mode + useEffect(() => { + if (!map.current) return; + if (!value || (!readonly && overlayRef.current)) { + return; + } + const nextOverlay = setOverlay(); + setupOverlay(nextOverlay); + // Focus on the overlay + setFitView([nextOverlay]); + }, [value, needUpdateFlag, type, disabled, readonly, setOverlay, setFitView, setupOverlay]); + + useEffect(() => { + if (!accessKey || map.current || !mapContainerRef.current) return; + let loader: Loader; + try { + loader = new Loader({ + apiKey: accessKey, + version: 'weekly', + language: api.auth.getLocale(), + }); + } catch (err) { + setErrMessage(t('Load google maps failed, Please check the Api key and refresh the page')); + return; + } + + // google maps api error + const error = console.error; + console.error = (err, ...args) => { + if (err?.includes('InvalidKeyMapError')) { + setErrMessage(t('Load google maps failed, Please check the Api key and refresh the page')); + } + error(err, ...args); + }; + + Promise.all([loader.importLibrary('drawing'), loader.importLibrary('core'), loader.importLibrary('geometry')]) + .then(async (res) => { + const center = await getCurrentPosition(); + map.current = new google.maps.Map(mapContainerRef.current, { + zoom, + center, + mapTypeId: google.maps.MapTypeId.ROADMAP, + zoomControl: false, + streetViewControl: false, + panControl: false, + mapTypeControl: false, + fullscreenControl: false, + }); + setErrMessage(''); + forceUpdate([]); + }) + .catch((err) => { + if (err instanceof Error) { + setErrMessage(err.message); + return; + } + }); + + return () => { + map.current?.unbindAll(); + map.current = null; + drawingManagerRef.current?.unbindAll(); + }; + }, [accessKey, api.auth, type, zoom]); + + useEffect(() => { + if (!map.current || !type || disabled || drawingManagerRef.current) return; + createDraw(); + }, [createDraw, disabled, needUpdateFlag, type]); + + useImperativeHandle(ref, () => ({ + setOverlay, + getOverlay, + setFitView, + createDraw, + map: map.current, + overlay: overlayRef.current, + drawingManager: drawingManagerRef.current, + errMessage, + })); + + const onReset = useMemoizedFn(() => { + const ok = () => { + toRemoveOverlay(); + drawingManagerRef.current.setDrawingMode(drawingMode.current); + onChange?.(null); + }; + Modal.confirm({ + title: t('Clear the canvas'), + content: t('Are you sure to clear the canvas?'), + okText: t('Confirm'), + cancelText: t('Cancel'), + onOk() { + ok(); + }, + }); + }); + + if (!accessKey || errMessage) { + return ( + navigate('/admin/settings/map/configuration?tab=google')}> + {t('Go to the configuration page')} + + } + message={errMessage || t('Please configure the Api key first')} + type="error" + /> + ); + } + + return ( +
+ {!map.current && ( +
+ +
+ )} + {!disabled ? ( + <> + {map.current && } +
+ +
+ {type === 'lineString' || type === 'polygon' ? ( +
+ +
+ ) : null} +
+ +
+ + ) : null} +
+
+ ); + }, +); diff --git a/packages/plugins/map/src/client/components/GoogleMaps/Search.tsx b/packages/plugins/map/src/client/components/GoogleMaps/Search.tsx new file mode 100644 index 000000000..3a81148d5 --- /dev/null +++ b/packages/plugins/map/src/client/components/GoogleMaps/Search.tsx @@ -0,0 +1,107 @@ +import { css } from '@emotion/css'; +import { useDebounceFn } from 'ahooks'; +import { message, RefSelectProps, Select } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import { useMapTranslation } from '../../locale'; + +interface SearchProps { + toCenter: (p: any) => void; + mapRef: React.RefObject; +} + +export const Search = (props: SearchProps) => { + const { toCenter, mapRef } = props; + const { t } = useMapTranslation(); + const placeSearchRef = useRef(); + const [options, setOptions] = useState([]); + + useEffect(() => { + google.maps + .importLibrary('places') + .then((places: google.maps.PlacesLibrary) => { + placeSearchRef.current = new places.AutocompleteService(); + }) + .catch(() => { + message.error('Please configure the Google API Key correctly'); + }); + }, [mapRef]); + + const { run: onSearch } = useDebounceFn( + (keyword) => { + if (!placeSearchRef.current) { + return; + } + placeSearchRef.current.getPlacePredictions( + { + input: keyword || ' ', + }, + (result, status) => { + if (status === google.maps.places.PlacesServiceStatus.OK) { + setOptions( + result.map((item: any) => { + const structured = item.structured_formatting; + return { + ...item, + label: `${structured.main_text}${structured.secondary_text ? ' ' + structured.secondary_text : ''}`, + value: item.place_id, + }; + }), + ); + } else { + setOptions([]); + return; + } + }, + ); + }, + { + wait: 300, + }, + ); + + const onSelect = (value) => { + const place = options.find((o) => { + return o.value === value; + }); + const service = new google.maps.places.PlacesService(mapRef.current); + service.getDetails( + { + placeId: place.place_id, + fields: ['geometry'], + }, + (result, status) => { + if (status === google.maps.places.PlacesServiceStatus.OK) { + toCenter(result.geometry.location); + } + }, + ); + }; + + return ( +
+ +
+ ); +}; + +export default Search; diff --git a/packages/plugins/map/src/client/components/GoogleMaps/index.ts b/packages/plugins/map/src/client/components/GoogleMaps/index.ts new file mode 100644 index 000000000..8440c742b --- /dev/null +++ b/packages/plugins/map/src/client/components/GoogleMaps/index.ts @@ -0,0 +1,2 @@ +export * from './Block'; +export * from './Map'; diff --git a/packages/plugins/map/src/client/components/GoogleMaps/utils.ts b/packages/plugins/map/src/client/components/GoogleMaps/utils.ts new file mode 100644 index 000000000..39b3ce2b8 --- /dev/null +++ b/packages/plugins/map/src/client/components/GoogleMaps/utils.ts @@ -0,0 +1,33 @@ +export const getIcon = (url) => { + return { + url, + scaledSize: { + width: 19, + height: 32, + }, + labelOrigin: new google.maps.Point(19 / 2, 32 + 10), + } as google.maps.Icon; +}; + +export const getCurrentPosition: () => Promise<{ lat: number; lng: number }> = () => { + return new Promise((resolve) => { + const defaultLatLng = () => { + // google company lat and lng + resolve({ lat: 37.4224764, lng: -122.0842499 }); + }; + + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + resolve({ lat: latitude, lng: longitude }); + }, + () => { + defaultLatLng(); + }, + ); + } else { + defaultLatLng(); + } + }); +}; diff --git a/packages/plugins/map/src/client/components/Map.tsx b/packages/plugins/map/src/client/components/Map.tsx index 0342d8d38..374a97d4d 100644 --- a/packages/plugins/map/src/client/components/Map.tsx +++ b/packages/plugins/map/src/client/components/Map.tsx @@ -1,9 +1,10 @@ +import { css } from '@emotion/css'; import { connect, mapReadPretty } from '@formily/react'; import React from 'react'; -import AMapComponent, { AMapComponentProps } from './AMap'; -import ReadPretty from './ReadPretty'; -import { css } from '@emotion/css'; +import { AMapComponentProps } from './AMap'; import Designer from './Designer'; +import { MapComponent } from './MapComponent'; +import ReadPretty from './ReadPretty'; type MapProps = AMapComponentProps; @@ -18,7 +19,7 @@ const InternalMap = connect((props: MapProps) => { } `} > - {props.mapType ? : null} + ); }, mapReadPretty(ReadPretty)); @@ -29,4 +30,4 @@ const Map = InternalMap as typeof InternalMap & { Map.Designer = Designer; -export default Map; +export { Map }; diff --git a/packages/plugins/map/src/client/components/MapBlock.tsx b/packages/plugins/map/src/client/components/MapBlock.tsx new file mode 100644 index 000000000..278b66ea1 --- /dev/null +++ b/packages/plugins/map/src/client/components/MapBlock.tsx @@ -0,0 +1,24 @@ +import React, { useMemo } from 'react'; +import { useMapTranslation } from '../locale'; +import { AMapBlock } from './AMap'; +import { GoogleMapsBlock } from './GoogleMaps'; + +const MapBlocks = { + amap: AMapBlock, + google: GoogleMapsBlock, +}; + +export const MapBlockComponent: React.FC = (props) => { + const { t } = useMapTranslation(); + const { mapType } = props; + + const Component = useMemo(() => { + return MapBlocks[mapType]; + }, [mapType]); + + if (!Component) { + return
{t(`The ${mapType} cannot found`)}
; + } + + return ; +}; diff --git a/packages/plugins/map/src/client/components/MapComponent.tsx b/packages/plugins/map/src/client/components/MapComponent.tsx new file mode 100644 index 000000000..7376e49d2 --- /dev/null +++ b/packages/plugins/map/src/client/components/MapComponent.tsx @@ -0,0 +1,23 @@ +import React, { useMemo } from 'react'; +import { useMapTranslation } from '../locale'; +import { AMapComponent } from './AMap'; +import { GoogleMapsComponent } from './GoogleMaps'; + +const MapComponents = { + amap: AMapComponent, + google: GoogleMapsComponent, +}; + +export const MapComponent = React.forwardRef((props, ref) => { + const { t } = useMapTranslation(); + const { mapType } = props; + const Component = useMemo(() => { + return MapComponents[mapType]; + }, [mapType]); + + if (!Component) { + return
{t(`The ${mapType} cannot found`)}
; + } + + return ; +}); diff --git a/packages/plugins/map/src/client/components/ReadPretty.tsx b/packages/plugins/map/src/client/components/ReadPretty.tsx index ce81b2425..4b36ea091 100644 --- a/packages/plugins/map/src/client/components/ReadPretty.tsx +++ b/packages/plugins/map/src/client/components/ReadPretty.tsx @@ -1,7 +1,7 @@ -import { useField, useFieldSchema, useForm } from '@formily/react'; -import { EllipsisWithTooltip, useCollection } from '@nocobase/client'; -import React, { useEffect } from 'react'; -import AMapComponent from './AMap'; +import { useFieldSchema, useForm } from '@formily/react'; +import { EllipsisWithTooltip, useCollection, useFieldTitle } from '@nocobase/client'; +import React from 'react'; +import { MapComponent } from './MapComponent'; const ReadPretty = (props) => { const { value } = props; @@ -9,13 +9,8 @@ const ReadPretty = (props) => { const { getField } = useCollection(); const collectionField = getField(fieldSchema.name); const mapType = props.mapType || collectionField?.uiSchema['x-component-props']?.mapType; - const field = useField(); const form = useForm(); - useEffect(() => { - if (!field.title && collectionField?.uiSchema?.title) { - field.title = collectionField?.uiSchema.title; - } - }, collectionField?.title); + useFieldTitle(); if (!form.readPretty) { return ( @@ -27,7 +22,7 @@ const ReadPretty = (props) => { ); } - return mapType === 'amap' ? : null; + return ; }; export default ReadPretty; diff --git a/packages/plugins/map/src/client/components/index.ts b/packages/plugins/map/src/client/components/index.ts new file mode 100644 index 000000000..e999c21cb --- /dev/null +++ b/packages/plugins/map/src/client/components/index.ts @@ -0,0 +1,6 @@ +export * from './AMap'; +export * from './Configuration'; +export * from './GoogleMaps'; +export * from './Map'; +export * from './MapBlock'; +export * from './MapComponent'; diff --git a/packages/plugins/map/src/client/constants.ts b/packages/plugins/map/src/client/constants.ts index ee2487c5f..215ffa2d1 100644 --- a/packages/plugins/map/src/client/constants.ts +++ b/packages/plugins/map/src/client/constants.ts @@ -4,3 +4,8 @@ export const MapTypes = [ { label: generateNTemplate('AMap'), value: 'amap' }, { label: generateNTemplate('Google Maps'), value: 'google' }, ]; + +export const selectedImage = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAA/CAMAAAC7OkrPAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAEJQTFRFAAAA8Yti8Yti8Itj8Iti74tj8Itj8YtiKwADKwADKhw5Kh07KwADKwADKwADKwAD4odn1YBlKwADKwADKwADKwAD/5y7LQAAABZ0Uk5TAP/8/f/B/PYOHjY3CCozLP3XCSkvMhA05K4AAAC4SURBVHic7dXLDoIwEIXhwSsoLSrw/q8qbSAiMy3/oivj2czmS5omnVORdapVJJFKBSEL2mrrUurbpdXa5dTH5dXi9tTsGNtX0TFG1OT+7PdY2YdEGd0FuFmUwXWm5UAZbCTKYA3SUrXZQTHLHbUy2OlsMO0ultLsajLlbEWZMEUZ+ig5E6YKM2GqMBOmGKubW1D3ps6g1nnvA5uGa5Os85E9nmF2ebYkzeKhc9wre4V+GMeh317hDfXgCWigIGJbAAAAAElFTkSuQmCC'; +export const defaultImage = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAA/CAYAAACM5Lr9AAAFkklEQVR42s3VaWxUVRjG8ddiEKPRxAQTCVSWLnZaWmjpTgulpYUBExP9Agou4IKAogUKAgItlNJCgUIXINFPfsFoDCjibkQRI0QIICAQWUpZugKtpcv0+DxyYqaTe2/vTLf58Etu7znnPf+0k44opQw5i5qsjIFs2APHoBZatFq+02vZeq94yyKs0dOjsAzOTCtuVtNKWtX0UpeaXqHU07tgt4ZnvuMa93Avz/CsniE2WIQV3iEaCDnQ8F/MTgZ4h2d4ljNgmZ4pFszDpmIRIuFP57YWDMcFu7uHMziLMyEKxJBl2MbbM6DZub2NQ3sUZ3K2vkMMGIdNKbi1ADqcZe1q2i7VKzibd8BCEE8GUQ3PQ4ez3KWcGNCbeAfv0neKm85hWRvqo+Hu1LJ2HuwTvAt3tkAciKbDIDO/7iE4l7X9rpqCA30pa3uLwt3nYRAIuIfV5mcWN6msnapf8G405IGQMGry+prB0DiloqPfwng3G3SLDltXnTt5S6PK3NnRr9jAFhDJyLsZAJcz8SGcjOr+xAa0XGGTpOfeSMrYUKcyKlx+gS1oSkbY9VXpm2+rjHKXX2ALm2TS2qq96duaVHp5u19gC5skbc3V05N23FWT8NIfsIVNkra6siGttE2llbX7h9JWhaZ6mfj+lfaJZW3Kn6DJJRNWXWqbgN+Y39jRqtDUKKkrL15PxfdjammrfyhpVmiqlNQVfx9KwX/cFJT6A7awSVLeu1CeUlinxu9o8QsphbUKTWUyfvn5Wcm511QSXvoDtrBJkpedewzaE/E58wdsYZMopSQ55+z+pKK6fo9KKqpXaDkAIgxLWnomK3H1JZVQ0tyv2ICW6XAvLHHJ6QD4K6GgRsVjQ3/g3WzQLffCKGHJqfHgisOXaNy2f/oU7+TdukGIUW5xJ0vic6+oWGzsS7yTd4NoncPiF594GC7FFlSrcVub+gTv4p36btE6h1Fc9nFn7IpzKmZrY5/gXbwTxI0O8xD77rGPYtZeVtE42Jt4B+8C8WAcNu6dPwZDzVh8PYzdcqdXcDbvgMdBPDHEOG7R0Xkxqy6oKAzpDZzNO0AMmIfFvH1kABwZk1epIotv9yjOxOyj+g4xYB4W/dbvlAAdozGsJ3Gmni0mzMPGLvyN6IMo/NojNt/qEZyFmR+CWLAIW3CYaDDUR+D/TTgGdwdncJaeKRbMw8bM/9XdosjlZ5VjU0O3cAZngXTBPCzqzV/cPQAXHeuqVBgu8IVj/TXFGXqWdME8LHLez55mRSw9qUI31fuEZzFjNogNFmFvHPQUACfC8IUbUlTvlbDcSsWzeobYYB42+vWfjDwbnn3c6zCewdnnQGwyD4t47UcjAXA2JO+qCi6ss4V7eUafFZuswn4wM8ex+LgKwqV2cC/OzAXxgnlY+KvfmxkIVaPyr6tRhbWWuId79RnxgnmYY+53VlaHLj2lRm6stRSSc0ph7xoQL1mEzfnWyhPQNrKgGgE1hrjGPTAExEvmYWGvfNOVPUErz6vhiDDCNez5GMQHVmFfd2Vq6MLD6smCGkNc4x4QH5iHPfXyV125H64NX1eFkOpO+I5reo/4wDws9KUDdhSPxFdNIGLc8R3XQHxkFfalHYnB8w6qYRuqO+E7roH4yDws5MX9dtwHlYF5lQi6qYjPfKfXxEcWYbO/sKtsBP50QxFFI/D/De8qQLrBPCwYizY9M2r+of/D+Mx3ID6zDJv1uaVwGfAIBDqGJsbhZ9eQ/BuK+OwYlhzPNe7hXh9Yhe0zwqBB4IAYouCZn1wIXH5GEZ/d1sjBMzzrBfOwoBf2GWFYBMS4C03JWRA087OaoBmf3uCz5zrP8Kx9lmF7jXiE2RbBs95ghB0MIo8/pS0OeJAzvOBLmKY//BAGURBN+jmMa3qPkLdh/wKOL8SpLbnYFgAAAABJRU5ErkJggg=='; diff --git a/packages/plugins/map/src/client/index.tsx b/packages/plugins/map/src/client/index.tsx index 5c2385b91..a9bc6c048 100644 --- a/packages/plugins/map/src/client/index.tsx +++ b/packages/plugins/map/src/client/index.tsx @@ -6,12 +6,11 @@ import { } from '@nocobase/client'; import React, { useContext } from 'react'; import { MapBlockOptions } from './block'; -import Configuration from './components/Configuration'; -import Map from './components/Map'; +import { Configuration, Map } from './components'; import { interfaces } from './fields'; import { MapInitializer } from './initialize'; -import { useMapTranslation } from './locale'; import './locale'; +import { useMapTranslation } from './locale'; const MapProvider = React.memo((props) => { const ctx = useContext(CollectionManagerContext); diff --git a/packages/plugins/map/src/client/locale/zh-CN.ts b/packages/plugins/map/src/client/locale/zh-CN.ts index 429a41655..55b864cc9 100644 --- a/packages/plugins/map/src/client/locale/zh-CN.ts +++ b/packages/plugins/map/src/client/locale/zh-CN.ts @@ -43,6 +43,8 @@ const locale = { 'Edit description': '编辑描述', 'Map field': '地图字段', 'Marker field': '标记字段', + 'Load google maps failed, Please check the Api key and refresh the page': + '加载谷歌地图失败,请检查 Api key 并刷新页面', }; export default locale; diff --git a/packages/plugins/map/src/client/types.ts b/packages/plugins/map/src/client/types.ts new file mode 100644 index 000000000..0ffb87f8c --- /dev/null +++ b/packages/plugins/map/src/client/types.ts @@ -0,0 +1 @@ +export type MapEditorType = 'point' | 'polygon' | 'lineString' | 'circle'; diff --git a/packages/plugins/map/src/client/utils.ts b/packages/plugins/map/src/client/utils.ts new file mode 100644 index 000000000..e69de29bb diff --git a/yarn.lock b/yarn.lock index 0a8a47dde..0ea9e4f3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4007,6 +4007,13 @@ version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" +"@googlemaps/js-api-loader@^1.16.1": + version "1.16.1" + resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.1.tgz#db74c851f8e26a50f578a07c195a87e1fb60ae90" + integrity sha512-x6M48e2nkIrp1KGNwXEVMRTCzruBw/w1HItlhVXL6Lp5pKuTcJEfOCE4tjPk3n6kLcMuJYXE8aluYMmakn7tTw== + dependencies: + fast-deep-equal "^3.1.3" + "@hapi/hoek@^11.0.2": version "11.0.2" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.2.tgz#cb3ea547daac7de5c9cf1d960c3f35c34f065427" @@ -6459,6 +6466,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/google.maps@^3.53.4": + version "3.53.4" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.53.4.tgz#741442764ebaef1a6705f3ab2c047ffeba333020" + integrity sha512-IiDAYTONQEKCBssPtoM0XqWF8YIIk2leba4NOf9qVQ/d8l5gLuS3QT87TrX6/u8rMonQAXC9KEDmM4q7sT6MWg== + "@types/graceful-fs@^4.1.2", "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -21935,7 +21947,7 @@ react-dom-factories@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/react-dom-factories/-/react-dom-factories-1.0.2.tgz#eb7705c4db36fb501b3aa38ff759616aa0ff96e0" -react-dom@18.1.0, react-dom@^16.8.6, react-dom@^18.0.0: +react-dom@17.x, react-dom@18.1.0, react-dom@^16.8.6, react-dom@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== @@ -22156,7 +22168,7 @@ react-to-print@^2.14.7: version "2.14.13" resolved "https://registry.yarnpkg.com/react-to-print/-/react-to-print-2.14.13.tgz#cd0349f7ef93c8af5120fac0ef6c4f3d100df490" -react@18.1.0, react@^16.8.6, react@^18.0.0: +react@17.x, react@18.1.0, react@^16.8.6, react@^18.0.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==