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:
Dunqing 2023-06-30 11:08:53 +08:00 committed by GitHub
parent 717b21d37a
commit 4bf722326c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1443 additions and 377 deletions

View File

@ -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"
}
}

View File

@ -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 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 fieldComponentProps = field?.uiSchema?.['x-component-props'];
return <MapBlockComponent {...fieldComponentProps} {...props} />;
};
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',
});
}
}

View File

@ -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();

View 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',
});
}
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,2 @@
export * from './Block';
export * from './Map';

View File

@ -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;

View 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',
});
}

View 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>
);
},
);

View 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;

View File

@ -0,0 +1,2 @@
export * from './Block';
export * from './Map';

View File

@ -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();
}
});
};

View File

@ -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 };

View 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} />;
};

View 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} />;
});

View File

@ -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;

View File

@ -0,0 +1,6 @@
export * from './AMap';
export * from './Configuration';
export * from './GoogleMaps';
export * from './Map';
export * from './MapBlock';
export * from './MapComponent';

View File

@ -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==';

View File

@ -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);

View File

@ -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;

View File

@ -0,0 +1 @@
export type MapEditorType = 'point' | 'polygon' | 'lineString' | 'circle';

View File

View File

@ -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==