feat(map-plugin): support google map (#2027)
* feat(map-plugin): support google map * fix: should render current position * feat: support google map in field * feat: support google map block * feat: support connect data * fix: update * feat: support autocomplete instated of search * feat: support language * fix: drawing not work * feat: support same labe lstyle * feat: use better configuration * fix: avoid rerender * chore: update deps * feat: add tips for lineString, Close T-591 * fix: zoom to big * feat: improve api key incorrect flow * fix: remove unreleated file * feat: improve setFitView * feat: update tips * feat: improve code * fix: avoid crash, Close T-713 --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
717b21d37a
commit
4bf722326c
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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<AMapForwardedRefProps>();
|
||||
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 fieldComponentProps = field?.uiSchema?.['x-component-props'];
|
||||
return <MapBlockComponent {...fieldComponentProps} {...props} />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 999;
|
||||
`}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Button
|
||||
style={{
|
||||
color: !selectingMode ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('');
|
||||
}}
|
||||
icon={<EnvironmentOutlined />}
|
||||
></Button>
|
||||
<Button
|
||||
style={{
|
||||
color: selectingMode === 'selection' ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('selection');
|
||||
}}
|
||||
icon={<ExpandOutlined />}
|
||||
></Button>
|
||||
{selectingMode === 'selection' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
title={t('Confirm selection')}
|
||||
onClick={onSelectingComplete}
|
||||
></Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<MapBlockDrawer record={record} setVisible={setRecord} />
|
||||
<AMap
|
||||
{...field?.uiSchema?.['x-component-props']}
|
||||
ref={mapRefCallback}
|
||||
style={{ height: fixedBlock ? '100%' : null }}
|
||||
zoom={zoom}
|
||||
disabled
|
||||
block
|
||||
overlayCommonOptions={{
|
||||
strokeColor: '#F18b62',
|
||||
fillColor: '#F18b62',
|
||||
}}
|
||||
></AMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<ActionContextProvider value={{ visible: !!record, setVisible }}>
|
||||
<RecordProvider record={record}>
|
||||
<RecursionField schema={schema} name={schema.name} />
|
||||
</RecordProvider>
|
||||
</ActionContextProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
314
packages/plugins/map/src/client/components/AMap/Block.tsx
Normal file
314
packages/plugins/map/src/client/components/AMap/Block.tsx
Normal file
@ -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<AMapForwardedRefProps>();
|
||||
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 (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 999;
|
||||
`}
|
||||
>
|
||||
{isMapInitialization && !mapRef.current.errMessage ? (
|
||||
<Space direction="vertical">
|
||||
<Button
|
||||
style={{
|
||||
color: !selectingMode ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('');
|
||||
}}
|
||||
icon={<EnvironmentOutlined />}
|
||||
></Button>
|
||||
<Button
|
||||
style={{
|
||||
color: selectingMode === 'selection' ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('selection');
|
||||
}}
|
||||
icon={<ExpandOutlined />}
|
||||
></Button>
|
||||
{selectingMode === 'selection' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
title={t('Confirm selection')}
|
||||
onClick={onSelectingComplete}
|
||||
></Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
<MapBlockDrawer record={record} setVisible={setRecord} />
|
||||
<AMapComponent
|
||||
{...field?.uiSchema?.['x-component-props']}
|
||||
ref={mapRefCallback}
|
||||
style={{ height: fixedBlock ? '100%' : null }}
|
||||
zoom={zoom}
|
||||
disabled
|
||||
block
|
||||
overlayCommonOptions={{
|
||||
strokeColor: '#F18b62',
|
||||
fillColor: '#F18b62',
|
||||
}}
|
||||
></AMapComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<ActionContextProvider value={{ visible: !!record, setVisible }}>
|
||||
<RecordProvider record={record}>
|
||||
<RecursionField schema={schema} name={schema.name} />
|
||||
</RecordProvider>
|
||||
</ActionContextProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
@ -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<AMapForwardedRefProps, AMapComponentProps>((props, ref) => {
|
||||
export const AMapComponent = React.forwardRef<AMapForwardedRefProps, AMapComponentProps>((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<AMapForwardedRefProps, AMapComponentProps
|
||||
overlay: overlay.current,
|
||||
mouseTool: () => mouseTool.current,
|
||||
editor: () => editor.current,
|
||||
errMessage,
|
||||
}));
|
||||
|
||||
if (!accessKey || errMessage) {
|
||||
return (
|
||||
<Alert
|
||||
action={
|
||||
<Button type="primary" onClick={() => navigate('/admin/settings/map-configuration/configuration')}>
|
||||
<Button type="primary" onClick={() => navigate('/admin/settings/map/configuration')}>
|
||||
{t('Go to the configuration page')}
|
||||
</Button>
|
||||
}
|
||||
@ -458,5 +459,3 @@ const AMapComponent = React.forwardRef<AMapForwardedRefProps, AMapComponentProps
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AMapComponent;
|
@ -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<any>();
|
||||
@ -90,5 +90,3 @@ const Search = (props: SearchProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
2
packages/plugins/map/src/client/components/AMap/index.ts
Normal file
2
packages/plugins/map/src/client/components/AMap/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Block';
|
||||
export * from './Map';
|
@ -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<BaseConfigurationProps> = ({ 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 (
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item required name="accessKey" label={t('Access key')}>
|
||||
<Input disabled={isDisabled} />
|
||||
</Form.Item>
|
||||
<Form.Item required name="securityJsCode" label={t('securityJsCode or serviceHost')}>
|
||||
<Input disabled={isDisabled} />
|
||||
</Form.Item>
|
||||
<Form disabled={isDisabled} form={form} layout="vertical" onFinish={onSubmit}>
|
||||
{children}
|
||||
{isDisabled ? (
|
||||
<Button onClick={disableAction.toggle} type="ghost">
|
||||
<Button disabled={false} onClick={disableAction.toggle} type="ghost">
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
) : (
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
<Button disabled={false} type="primary" htmlType="submit">
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@ -60,9 +60,34 @@ const AMapConfiguration = ({ type }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AMapConfiguration = () => {
|
||||
const { t } = useMapTranslation();
|
||||
return (
|
||||
<BaseConfiguration type="amap">
|
||||
<Form.Item required name="accessKey" label={t('Access key')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item required name="securityJsCode" label={t('securityJsCode or serviceHost')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</BaseConfiguration>
|
||||
);
|
||||
};
|
||||
|
||||
const GoogleMapConfiguration = () => {
|
||||
const { t } = useMapTranslation();
|
||||
return (
|
||||
<BaseConfiguration type="google">
|
||||
<Form.Item required name="accessKey" label={t('Api key')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</BaseConfiguration>
|
||||
);
|
||||
};
|
||||
|
||||
const components = {
|
||||
amap: AMapConfiguration,
|
||||
google: () => <div>Coming soon</div>,
|
||||
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 (
|
||||
<Card bordered>
|
||||
<Tabs
|
||||
type="card"
|
||||
items={tabList.map((tab) => {
|
||||
return {
|
||||
key: tab.value,
|
||||
label: compile(tab.label),
|
||||
children: <tab.component type={tab.value} />,
|
||||
};
|
||||
<Tabs type="card" defaultActiveKey={search.get('tab')}>
|
||||
{tabList.map((tab) => {
|
||||
return (
|
||||
<Tabs.TabPane key={tab.value} tab={compile(tab.label)}>
|
||||
<tab.component type={tab.value} />
|
||||
</Tabs.TabPane>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Configuration;
|
||||
|
341
packages/plugins/map/src/client/components/GoogleMaps/Block.tsx
Normal file
341
packages/plugins/map/src/client/components/GoogleMaps/Block.tsx
Normal file
@ -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<GoogleMapForwardedRefProps>();
|
||||
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<google.maps.Polygon>();
|
||||
const overlaysRef = useRef<google.maps.MVCObject[]>([]);
|
||||
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 (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{isMapInitialization && (
|
||||
<>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 999;
|
||||
`}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Button
|
||||
style={{
|
||||
color: !selectingMode ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('');
|
||||
}}
|
||||
icon={<EnvironmentOutlined />}
|
||||
></Button>
|
||||
<Button
|
||||
style={{
|
||||
color: selectingMode === 'selection' ? '#F18b62' : undefined,
|
||||
borderColor: 'currentcolor',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelecting('selection');
|
||||
}}
|
||||
icon={<ExpandOutlined />}
|
||||
></Button>
|
||||
{selectingMode === 'selection' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
title={t('Confirm selection')}
|
||||
onClick={onSelectingComplete}
|
||||
></Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<MapBlockDrawer record={record} setVisible={setRecord} />
|
||||
</>
|
||||
)}
|
||||
<GoogleMapsComponent
|
||||
{...props}
|
||||
ref={mapRefCallback}
|
||||
style={{ height: fixedBlock ? '100%' : null }}
|
||||
zoom={zoom}
|
||||
disabled
|
||||
block
|
||||
overlayCommonOptions={{
|
||||
strokeColor: '#F18b62',
|
||||
fillColor: '#F18b62',
|
||||
}}
|
||||
></GoogleMapsComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 && (
|
||||
<ActionContextProvider value={{ visible: !!record, setVisible }}>
|
||||
<RecordProvider record={record}>
|
||||
<RecursionField schema={schema} name={schema.name} />
|
||||
</RecordProvider>
|
||||
</ActionContextProvider>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
470
packages/plugins/map/src/client/components/GoogleMaps/Map.tsx
Normal file
470
packages/plugins/map/src/client/components/GoogleMaps/Map.tsx
Normal file
@ -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<GoogleMapForwardedRefProps, GoogleMapsComponentProps>(
|
||||
(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<google.maps.drawing.DrawingManager>();
|
||||
const map = useRef<google.maps.Map>();
|
||||
const overlayRef = useRef<google.maps.Marker | google.maps.Polygon | google.maps.Polyline | google.maps.Circle>();
|
||||
const [needUpdateFlag, forceUpdate] = useState([]);
|
||||
const [errMessage, setErrMessage] = useState('');
|
||||
const api = useAPIClient();
|
||||
|
||||
const type = useMemo<MapEditorType>(() => {
|
||||
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<OverlayOptions>({
|
||||
strokeWeight: 5,
|
||||
strokeColor: '#4e9bff',
|
||||
fillColor: '#4e9bff',
|
||||
strokeOpacity: 1,
|
||||
editable: !disabled,
|
||||
draggable: !disabled,
|
||||
...overlayCommonOptions,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const mapContainerRef = useRef<HTMLDivElement>();
|
||||
const cleanupOverlayListenersRef = useRef<Set<() => 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 (
|
||||
<Alert
|
||||
action={
|
||||
<Button type="primary" onClick={() => navigate('/admin/settings/map/configuration?tab=google')}>
|
||||
{t('Go to the configuration page')}
|
||||
</Button>
|
||||
}
|
||||
message={errMessage || t('Please configure the Api key first')}
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
height: 500px;
|
||||
`}
|
||||
>
|
||||
{!map.current && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`}
|
||||
>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{!disabled ? (
|
||||
<>
|
||||
{map.current && <Search toCenter={toCenter} mapRef={map} />}
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
onClick={onFocusOverlay}
|
||||
disabled={!overlayRef.current}
|
||||
type="primary"
|
||||
shape="round"
|
||||
size="large"
|
||||
icon={<SyncOutlined />}
|
||||
></Button>
|
||||
</div>
|
||||
{type === 'lineString' || type === 'polygon' ? (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
`}
|
||||
>
|
||||
<Alert
|
||||
message={t('Click to select the starting point and double-click to end the drawing')}
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 2;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
disabled={!value}
|
||||
style={{
|
||||
height: '40px',
|
||||
}}
|
||||
onClick={onReset}
|
||||
type="primary"
|
||||
danger
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className={css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`}
|
||||
style={props?.style}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
107
packages/plugins/map/src/client/components/GoogleMaps/Search.tsx
Normal file
107
packages/plugins/map/src/client/components/GoogleMaps/Search.tsx
Normal file
@ -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<google.maps.Map>;
|
||||
}
|
||||
|
||||
export const Search = (props: SearchProps) => {
|
||||
const { toCenter, mapRef } = props;
|
||||
const { t } = useMapTranslation();
|
||||
const placeSearchRef = useRef<google.maps.places.AutocompleteService>();
|
||||
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 (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
width: calc(100% - 20px);
|
||||
`}
|
||||
>
|
||||
<Select
|
||||
id="google-map-search"
|
||||
showSearch
|
||||
allowClear
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
}}
|
||||
placeholder={t('Enter keywords to search')}
|
||||
filterOption={false}
|
||||
onSearch={onSearch}
|
||||
onSelect={onSelect}
|
||||
options={options}
|
||||
></Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
@ -0,0 +1,2 @@
|
||||
export * from './Block';
|
||||
export * from './Map';
|
@ -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();
|
||||
}
|
||||
});
|
||||
};
|
@ -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 ? <AMapComponent {...props} /> : null}
|
||||
<MapComponent {...props} />
|
||||
</div>
|
||||
);
|
||||
}, mapReadPretty(ReadPretty));
|
||||
@ -29,4 +30,4 @@ const Map = InternalMap as typeof InternalMap & {
|
||||
|
||||
Map.Designer = Designer;
|
||||
|
||||
export default Map;
|
||||
export { Map };
|
||||
|
24
packages/plugins/map/src/client/components/MapBlock.tsx
Normal file
24
packages/plugins/map/src/client/components/MapBlock.tsx
Normal file
@ -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<any> = (props) => {
|
||||
const { t } = useMapTranslation();
|
||||
const { mapType } = props;
|
||||
|
||||
const Component = useMemo(() => {
|
||||
return MapBlocks[mapType];
|
||||
}, [mapType]);
|
||||
|
||||
if (!Component) {
|
||||
return <div>{t(`The ${mapType} cannot found`)}</div>;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
23
packages/plugins/map/src/client/components/MapComponent.tsx
Normal file
23
packages/plugins/map/src/client/components/MapComponent.tsx
Normal file
@ -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<any, any>((props, ref) => {
|
||||
const { t } = useMapTranslation();
|
||||
const { mapType } = props;
|
||||
const Component = useMemo(() => {
|
||||
return MapComponents[mapType];
|
||||
}, [mapType]);
|
||||
|
||||
if (!Component) {
|
||||
return <div>{t(`The ${mapType} cannot found`)}</div>;
|
||||
}
|
||||
|
||||
return <Component ref={ref} {...props} />;
|
||||
});
|
@ -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' ? <AMapComponent readonly mapType={mapType} {...props}></AMapComponent> : null;
|
||||
return <MapComponent readonly mapType={mapType} {...props}></MapComponent>;
|
||||
};
|
||||
|
||||
export default ReadPretty;
|
||||
|
6
packages/plugins/map/src/client/components/index.ts
Normal file
6
packages/plugins/map/src/client/components/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './AMap';
|
||||
export * from './Configuration';
|
||||
export * from './GoogleMaps';
|
||||
export * from './Map';
|
||||
export * from './MapBlock';
|
||||
export * from './MapComponent';
|
@ -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==';
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
1
packages/plugins/map/src/client/types.ts
Normal file
1
packages/plugins/map/src/client/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type MapEditorType = 'point' | 'polygon' | 'lineString' | 'circle';
|
0
packages/plugins/map/src/client/utils.ts
Normal file
0
packages/plugins/map/src/client/utils.ts
Normal file
16
yarn.lock
16
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==
|
||||
|
Loading…
Reference in New Issue
Block a user