├── .prettierignore ├── .env.production ├── src ├── config │ └── index.js ├── index.js ├── components │ ├── SelectCity.js │ ├── IntersectionAddressFuc.js │ ├── SearchBox.js │ ├── SearchResult.js │ └── SelectCurrentPlace.js ├── marker-1.svg ├── marker-2.svg ├── utils │ └── common.js ├── index.css └── App.js ├── .env.development ├── .env.test ├── public ├── quick-meet.png ├── robots.txt ├── manifest.json └── index.html ├── .prettierrc.json ├── .gitignore ├── package.json ├── .env.example └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK = true 2 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | PUBLIC_URL="/quick-meet" 2 | SKIP_PREFLIGHT_CHECK = true 3 | -------------------------------------------------------------------------------- /public/quick-meet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YasinChan/quick-meet/HEAD/public/quick-meet.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 120, 5 | "trailingComma": "all", 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Quick Meet", 3 | "name": "Quick Meet", 4 | "icons": [ 5 | { 6 | "src": "quick-meet.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import { config as AmapReactConfig } from '@amap/amap-react'; 6 | 7 | AmapReactConfig.key = process.env.REACT_APP_AMAP_WEB_KEY; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.getElementById('root'), 14 | ); 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | 27 | .env 28 | -------------------------------------------------------------------------------- /src/components/SelectCity.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Cascader } from 'antd'; 3 | import { usePromise, loadCities } from '../utils/common'; 4 | 5 | export default function SelectCity(props) { 6 | const [cities] = usePromise(loadCities(), []); 7 | 8 | return ( 9 | { 13 | props.onCityChange && props.onCityChange(values); 14 | }} 15 | allowClear={false} 16 | placeholder="选择城市" 17 | style={{ width: 100 }} 18 | displayRender={(labels) => (labels.length > 0 ? labels[labels.length - 1] : '')} 19 | /> 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quick-meet", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@amap/amap-react": "^0.1.3", 7 | "@ant-design/icons": "^4.7.0", 8 | "@types/node": "^15.12.4", 9 | "@types/react": "^17.0.11", 10 | "@types/react-dom": "^17.0.8", 11 | "antd": "^4.16.13", 12 | "node-sass": "^6.0.1", 13 | "query-string": "^7.0.1", 14 | "react": "^17.0.2", 15 | "react-copy-to-clipboard": "^5.0.4", 16 | "react-dom": "^17.0.2", 17 | "react-scripts": "4.0.3" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app", 26 | "react-app/jest" 27 | ] 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | }, 41 | "devDependencies": { 42 | "prettier": "2.3.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 可以按照文档申请免费 Key https://lbs.amap.com/api/jsapi-v2/guide/abc/prepare 2 | # 并将服务平台为 Web 端 ( JSAPI ) 的 key 填在 .env 中 3 | REACT_APP_AMAP_WEB_KEY={amap key} 4 | 5 | # 2021.12.2 高德地图新增安全密钥,这里是配置 JSAPI key 搭配代理服务器并携带安全密钥转发 6 | # 具体 nginx 转发规则查看 https://lbs.amap.com/api/javascript-api/guide/abc/prepare 7 | # 举例:在开发环境时 nginx 中转发规则可以配置如下 8 | # server { 9 | # listen 3333; 10 | # server_name localhost; 11 | # 12 | # # 自定义地图服务代理 13 | # location /_AMapService/v4/map/styles { 14 | # set $args "$args&jscode=您的安全密钥"; 15 | # proxy_pass https://webapi.amap.com/v4/map/styles; 16 | # } 17 | # # 海外地图服务代理 18 | # location /_AMapService/v3/vectormap { 19 | # set $args "$args&jscode=您的安全密钥"; 20 | # proxy_pass https://fmap01.amap.com/v3/vectormap; 21 | # } 22 | # # Web服务API 代理 23 | # location /_AMapService/ { 24 | # set $args "$args&jscode=您的安全密钥"; 25 | # proxy_pass https://restapi.amap.com/; 26 | # } 27 | # } 28 | # 然后此时 $path 直接填 http://localhost:3333 即可 29 | REACT_APP_AMAP_SECRET_PATH={$path} -------------------------------------------------------------------------------- /src/components/IntersectionAddressFuc.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marker } from '@amap/amap-react'; 3 | import marker1 from '../marker-1.svg'; 4 | 5 | function IntersectionAddressFunc(props) { 6 | // 这里参考 https://dmitripavlutin.com/dont-overuse-react-usecallback/#3-a-good-use-case 7 | const { intersectionAddress, hover, setHover, setPath } = props; 8 | return ( 9 | <> 10 | {intersectionAddress.map((poi) => ( 11 | setHover(poi)} 26 | onMouseOut={() => setHover(null)} 27 | onClick={() => setPath(poi)} 28 | /> 29 | ))} 30 | 31 | ); 32 | } 33 | 34 | export default React.memo(IntersectionAddressFunc); 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quick meet 2 | 3 | > 一个可以快速找到聚会地点的[网站](https://qm.yasinchan.com/)。 4 | 5 | ## 小程序版本「快见」已发布,欢迎扫码体验! 6 | 7 | ![pic](https://file.yasinchan.com/y5lADKfOvXQD909wMUojrqPXPS8slvCh/%E4%B8%8B%E8%BD%BD.png) 8 | 9 | ## 灵感来源 10 | 11 | 某次,一位朋友约我和另一位陪他去 4s 店看车,我们都在上海工作,不过大家居住地相距甚远。当时为了找到对大家通勤都比较合适的 4s 店,我通过地图软件搜索了上海的所有该品牌车的 4s 店地址,再对比各自位置,通过目测,大体过滤出几个店,再通过地图路线规划得出的乘坐公共交通工具的时间,最后将多个店的地址和时间都列出来跟伙伴们一起讨论才得出目的地,这个过程比较繁琐,所以想到做这个工具。 12 | 13 | ## 产品逻辑 14 | 15 | 1. 输入多人当前地址,与目标场景如“海底捞”、“万达”,点击搜索后在地图上展示所有合适的目标地点。 16 | 2. 合适的目标地点是指多个当前地址根据彼此之间距离的最大值搜索周围目标点,得到交集处的地址在地图上展示出来。可以根据实际情况手动调节倍数,扩大搜索范围,倍数范围为 [1, 2]。 17 | 3. 可以通过开关在地图上展示出半径内所有的目标地点。 18 | 4. 点击地图上显示出来的标记点,会显示路径规划,可以选择公交换乘策略,也可以唤出高德地图客户端。 19 | 20 | ## 技术方案 21 | 22 | 基于 [AMap-React](https://jimnox.gitee.io/amap-react/) ,一款由 [AMap](https://amap.com/) 官方集成了[高德地图 API](https://lbs.amap.com/api/jsapi-v2/summary/) 与 React 的地图框架,配合 UI 库 [ant design](https://ant.design/index-cn),完成了以上构想。 23 | 24 | ## 开发方式 25 | 26 | 拉取代码后,请查看 .env.example 文件,需要从高德地图控制台免费[创建新的 key ](https://console.amap.com/dev/key/app),本地创建 .env 文件按 example 配置两个字段,另外还需要配置 nginx 转发规则,细节请查看[官方文档](https://lbs.amap.com/api/jsapi-v2/guide/abc/prepare)。 27 | 28 | 欢迎 PR 和提 issues~ 29 | 30 | [MIT License](https://opensource.org/licenses/MIT) 31 | -------------------------------------------------------------------------------- /src/components/SearchBox.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react'; 2 | import { Input, AutoComplete } from 'antd'; 3 | import { usePlugins } from '@amap/amap-react'; 4 | import { noop } from '../utils/common'; 5 | 6 | export default function SearchBox(props) { 7 | const AMap = usePlugins(['AMap.AutoComplete', 'AMap.DistrictSearch']); 8 | const [options, setOptions] = useState([]); 9 | const ac = useMemo(() => { 10 | if (AMap) return new AMap.AutoComplete(); 11 | else return null; 12 | }, [AMap]); 13 | 14 | const handleSearch = (kw) => { 15 | if (!ac) return; 16 | if (!kw) { 17 | setOptions([]); 18 | return; 19 | } 20 | ac.setCity(props.city); 21 | ac.search(kw, (status, result) => { 22 | if (status === 'complete' && result.tips) { 23 | const uniq = new Set(result.tips.map((tip) => tip.name)); 24 | setOptions(Array.from(uniq)); 25 | } else { 26 | setOptions([]); 27 | } 28 | }); 29 | }; 30 | 31 | const onSelect = (value, a) => { 32 | const { onSearch = noop } = props; 33 | onSearch(value); 34 | }; 35 | 36 | return ( 37 |
38 | 39 | ({ 43 | value, 44 | label:
{value}
, 45 | }))} 46 | onSelect={onSelect} 47 | onSearch={handleSearch} 48 | > 49 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/marker-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/marker-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/utils/common.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { loadAmap, loadPlugins } from '@amap/amap-react'; 3 | 4 | export function usePromise(promise, defaultValue = undefined) { 5 | const [result, setResult] = useState(defaultValue); 6 | const [error, setError] = useState(null); 7 | 8 | promise.then(setResult, setError); 9 | 10 | return [result, error]; 11 | } 12 | 13 | export const noop = () => {}; 14 | 15 | let _citiesPromise = null; 16 | export function loadCities() { 17 | if (!_citiesPromise) { 18 | _citiesPromise = loadAmap() 19 | .then(() => loadPlugins('AMap.DistrictSearch')) 20 | .then((AMap) => { 21 | const ds = new AMap.DistrictSearch({ 22 | level: 'country', 23 | subdistrict: 2, 24 | }); 25 | 26 | return new Promise((resolve) => { 27 | ds.search('中国', function (status, result) { 28 | const compare = (a, b) => { 29 | return parseInt(a.value, 10) - parseInt(b.value, 10); 30 | }; 31 | const options = result.districtList[0].districtList.map((province) => { 32 | const { adcode, name, districtList = [] } = province; 33 | const children = ['北京市', '天津市', '上海市', '重庆市'].includes(name) 34 | ? [] 35 | : districtList.map((city) => { 36 | return { 37 | value: city.adcode, 38 | label: city.name, 39 | }; 40 | }); 41 | children.sort(compare); 42 | return { 43 | value: adcode, 44 | label: name, 45 | children, 46 | }; 47 | }); 48 | options.sort(compare); 49 | resolve(options); 50 | }); 51 | }); 52 | }); 53 | } 54 | return _citiesPromise; 55 | } 56 | 57 | export function secondToDate(result) { 58 | if (!result) { 59 | return ''; 60 | } 61 | const h = Math.floor(result / 3600); 62 | const m = Math.floor((result / 60) % 60); 63 | return (h ? h + '小时' : '') + (m && m + '分钟'); 64 | } 65 | 66 | export const listDeDuplication = (preList, nextList, key) => { 67 | let identificationMap = new Map(); 68 | preList.map((preItem) => { 69 | preItem[key] && identificationMap.set(preItem[key], true); 70 | }); 71 | return nextList.filter((item) => { 72 | return !identificationMap.has(item[key]); 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | width: 100%; 5 | height: 100%; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | * { 11 | padding: 0; 12 | margin: 0; 13 | box-sizing: border-box; 14 | } 15 | .quick-meet__paragraph--small { 16 | font-size: 12px; 17 | color: rgba(0, 0, 0, 0.4); 18 | } 19 | 20 | .App { 21 | font-family: sans-serif; 22 | width: 100%; 23 | height: 100%; 24 | } 25 | 26 | .quick-meet { 27 | width: 100%; 28 | height: 100%; 29 | position: relative; 30 | } 31 | 32 | .quick-meet__card-wrap { 33 | position: absolute; 34 | z-index: 100; 35 | left: 10px; 36 | top: 10px; 37 | width: 400px; 38 | max-height: calc(100vh - 50px); 39 | overflow: auto; 40 | background: #fff; 41 | padding-top:50px; 42 | } 43 | .quick-meet__card-header { 44 | border-bottom: 2px solid rgba(0,0,0,0.2); 45 | display: flex; 46 | align-items: center; 47 | cursor: pointer; 48 | justify-content: space-between; 49 | position: fixed; 50 | width: 400px; 51 | max-height: calc(100vh - 50px); 52 | z-index: 1; 53 | background: #fff; 54 | padding: 0 24px; 55 | height: 50px; 56 | top: 10px; 57 | left: 10px; 58 | } 59 | .quick-meet__card-header-tip { 60 | position: absolute; 61 | top: 50%; 62 | transform: translateY(-50%); 63 | left: 50px; 64 | font-size: 12px; 65 | color: rgba(0, 0, 0, 0.2) 66 | } 67 | .quick-meet__card-header--active .quick-meet__card-header-icon { 68 | transform: rotate(0deg); 69 | } 70 | 71 | .quick-meet__card-header-icon { 72 | transform: rotate(90deg); 73 | } 74 | 75 | .quick-meet__card-header-title { 76 | font-size: 18px; 77 | font-weight: bold; 78 | } 79 | 80 | .quick-meet__card-info--active { 81 | display: none; 82 | } 83 | 84 | .quick-meet__card { 85 | width: 100%; 86 | } 87 | 88 | .quick-meet__add-address { 89 | display: block !important; 90 | margin-top: 20px; 91 | } 92 | 93 | .quick-meet__search-btn { 94 | margin: 10px 16px; 95 | } 96 | 97 | .quick-meet__row { 98 | padding: 10px 16px; 99 | } 100 | 101 | .quick-meet__popover-content { 102 | width: 200px; 103 | display: inline-block; 104 | } 105 | 106 | .quick-meet__collapse-panel>.ant-collapse-header { 107 | display: flex; 108 | align-items: center; 109 | } 110 | .quick-meet__collapse-panel-title { 111 | font-size: 20px; 112 | font-weight: bold; 113 | } 114 | 115 | @media only screen and (max-width: 499px) { 116 | .quick-meet__card-wrap, .quick-meet__card-header { 117 | width: calc(100% - 20px); 118 | } 119 | } -------------------------------------------------------------------------------- /src/components/SearchResult.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Card, List, Button, Pagination } from 'antd'; 3 | import { noop } from '../utils/common'; 4 | 5 | export default function SearchResult(props) { 6 | const { ps } = props; 7 | const pageSize = 20; 8 | const [status, setStatus] = useState('idle'); 9 | const [page, setPage] = useState(1); 10 | const [total, setTotal] = useState(0); 11 | const [results, setResults] = useState([]); 12 | 13 | useEffect(() => { 14 | setPage(1); 15 | setTotal(0); 16 | setResults([]); 17 | if (ps && props.city) { 18 | ps.setCity(props.city); 19 | } 20 | }, [ps, props.query, props.city]); 21 | 22 | useEffect(() => { 23 | if (!ps) return; 24 | setStatus('searching'); 25 | ps.setPageSize(pageSize); 26 | ps.setPageIndex(page); 27 | ps.search(props.query, (status, result) => { 28 | const { onResult = noop } = props; 29 | if (status === 'complete' && result.poiList) { 30 | setStatus('success'); 31 | setResults(result.poiList.pois); 32 | setTotal(result.poiList.count); 33 | onResult(result.poiList.pois); 34 | } else { 35 | setStatus('failed'); 36 | setResults([]); 37 | setTotal(0); 38 | onResult([]); 39 | } 40 | }); 41 | }, [ps, props.query, page]); // eslint-disable-line react-hooks/exhaustive-deps 42 | 43 | const renderPagination = () => { 44 | if (total <= 0) return null; 45 | return ( 46 | setPage(p)} /> 47 | ); 48 | }; 49 | 50 | return ( 51 | 56 | 返回 57 | 58 | } 59 | headStyle={{ 60 | padding: '0 12px', 61 | }} 62 | bodyStyle={{ 63 | maxHeight: '450px', 64 | overflowY: 'scroll', 65 | padding: '0 12px 24px', 66 | }} 67 | > 68 | ( 72 | props.onSelect && props.onSelect(poi)} style={{ cursor: 'pointer' }}> 73 | 74 | 75 | )} 76 | header={renderPagination()} 77 | footer={renderPagination()} 78 | /> 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Quick Meet 25 | 34 | 35 | 36 | 43 | 44 | 45 | 46 |
47 | 57 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/components/SelectCurrentPlace.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Card, Tooltip, Button, Modal, List } from 'antd'; 3 | import SearchBox from './SearchBox'; 4 | import SearchResult from './SearchResult'; 5 | import SelectCity from './SelectCity'; 6 | import { PlusOutlined } from '@ant-design/icons'; 7 | 8 | export default function SelectCurrentPlace(props) { 9 | const { $map, ps, setHover, address, setAddress, city, setCity, setIntersectionAddress } = props; 10 | 11 | const [mode, setMode] = useState('input'); 12 | const [query, setQuery] = useState(''); 13 | const [searchBoxVisible, setSearchBoxVisible] = useState(false); 14 | 15 | const clearSearch = () => { 16 | setHover(null); 17 | }; 18 | 19 | const handleSearch = (query) => { 20 | setQuery(query); 21 | }; 22 | 23 | return ( 24 | <> 25 | { 28 | setAddress([]); 29 | setCity(values); 30 | setIntersectionAddress([]); 31 | $map.current.setCity(values[values.length - 1]); 32 | setSearchBoxVisible(true); 33 | }} 34 | /> 35 | {address.length > 0 && ( 36 | ( 40 | { 45 | const addressFilter = address.filter((ad) => ad.id !== poi.id); 46 | setAddress(addressFilter); 47 | }} 48 | > 49 | 删除 50 | , 51 | ]} 52 | > 53 | 54 | 55 | )} 56 | /> 57 | )} 58 | {city.length > 0 && ( 59 | 60 | , 88 | ]} 89 | > 90 | {mode === 'input' && ( 91 | { 94 | clearSearch(); 95 | handleSearch(query); 96 | setMode('result'); 97 | }} 98 | city={city} 99 | /> 100 | )} 101 | {mode === 'result' && ( 102 | { 107 | clearSearch(); 108 | setMode('input'); 109 | }} 110 | onSelect={(poi) => { 111 | setHover(poi); 112 | setAddress((oldVal) => [...oldVal, poi]); 113 | setSearchBoxVisible(false); 114 | if ($map.current) { 115 | $map.current.setZoomAndCenter(17, [poi.location.lng, poi.location.lat], true); 116 | } 117 | clearSearch(); 118 | setMode('input'); 119 | }} 120 | /> 121 | )} 122 | 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'; 2 | import './index.css'; 3 | import 'antd/dist/antd.css'; 4 | import { Amap, Marker } from '@amap/amap-react'; 5 | import { 6 | Button, 7 | Input, 8 | message, 9 | List, 10 | Slider, 11 | Row, 12 | Col, 13 | Card, 14 | Popover, 15 | Select, 16 | Switch, 17 | Modal, 18 | notification, 19 | } from 'antd'; 20 | import { usePlugins } from '@amap/amap-react'; 21 | import SelectCurrentPlace from './components/SelectCurrentPlace'; 22 | import { QuestionCircleOutlined, RightOutlined, InfoCircleOutlined, CopyOutlined } from '@ant-design/icons'; 23 | import marker1 from './marker-1.svg'; 24 | import marker2 from './marker-2.svg'; 25 | import queryString from 'query-string'; 26 | import { listDeDuplication, secondToDate } from './utils/common'; 27 | import { CopyToClipboard } from 'react-copy-to-clipboard'; 28 | import IntersectionAddressFunc from './components/IntersectionAddressFuc'; 29 | 30 | const MAX_SEARCH_PAGE = 3; // 最大搜索页数。searchNearBy 方法一次只能搜索一页最多 50 条数据,这里设置最大页数,防止加载太多。 31 | // 点击目标场所的标识按钮需要各个用户当前位置到标识处的路径信息, 32 | // 但发现 AMap.Transfer 方法一个实例只能绘制一条路径,所以需要多个实例,同时用完即销毁。 33 | let tfArr = []; 34 | export default function App() { 35 | const { Option } = Select; 36 | 37 | const [isLoading, setIsLoading] = useState(false); // 是否正在搜索 38 | const [isHeaderActive, setIsHeaderActive] = useState(true); // 是否展开 39 | const [isShowInfoModal, setIsShowInfoModal] = useState(false); // 40 | const [isShowFinal, setIsShowFinal] = useState(false); // 41 | const [currentSelectAddress, setCurrentSelectAddress] = useState(''); // 点击开始搜索后,点击地图标记时此处的地址 42 | const [currentRoutePlan, setCurrentRoutePlan] = useState([]); // 点击开始搜索后,点击地图标记时此时的路径规划 43 | 44 | const [infoPopover, setInfoPopover] = useState(false); // 选择范围倍数的 info 45 | const [distanceRatio, setDistanceRatio] = useState(1); // 选择范围倍数的 46 | const policy = useRef(); // 公交换乘策略 value 47 | // 注:这里使用 useRef 的原因在于,policy 需要在 setPath 中频繁使用,state 在 useCallback 中存在在某些情况无法更新的情况 参考:https://zhuanlan.zhihu.com/p/56975681 48 | const [policyTitle, setPolicyTitle] = useState(''); // 公交换乘策略 title 49 | const [isShowAllAddress, setIsShowAllAddress] = useState(false); // 是否展示所有地址 50 | 51 | const [hover, setHover] = useState(null); // 标记的 hover 52 | const [address, setAddress] = useState([]); // 选择的当前位置地址 53 | const [destination, setDestination] = useState(''); // 目的地 54 | const [disabled, setDisabled] = useState(true); // 开始搜索按钮的置灰 55 | const [city, setCity] = useState([]); // 设置的城市 56 | const [intersectionAddress, setIntersectionAddress] = useState([]); // 结果中有交集的场所 57 | const [circleOverly, setCircleOverly] = useState([]); // 所有的圆形覆盖物 58 | const [activeKey, setActiveKey] = useState(['1']); // 折叠面板中需要展开的部分 59 | const [transferPolicy, setTransferPolicy] = useState([]); // 公交换乘策略 map 60 | 61 | const AMap = usePlugins(['AMap.CallAMap', 'AMap.PlaceSearch', 'AMap.GeometryUtil', 'AMap.Circle', 'AMap.Transfer']); 62 | const $map = useRef(null); 63 | 64 | const ac = useMemo(() => { 65 | if (AMap) { 66 | // 抓取了源码发现了这个方法,起到和 v1.4 中的 searchOnAMAP 方法类似的效果 67 | return new AMap.CallAMap(); 68 | } 69 | }, [AMap]); 70 | const ps = useMemo(() => { 71 | // 搜索 72 | if (AMap) { 73 | return new AMap.PlaceSearch({ 74 | city: '上海市', 75 | pageSize: 50, 76 | citylimit: true, 77 | }); 78 | } else { 79 | return null; 80 | } 81 | }, [AMap]); 82 | const gu = useMemo(() => { 83 | // 高德地图提供的函数方法 84 | if (AMap) { 85 | return AMap.GeometryUtil; 86 | } 87 | }, [AMap]); 88 | 89 | useEffect(() => { 90 | if (AMap) { 91 | policy.current = AMap.TransferPolicy.LEAST_TIME; 92 | setPolicyTitle('最快捷模式'); 93 | const tpList = [ 94 | { 95 | title: '最快捷模式', 96 | value: AMap.TransferPolicy.LEAST_TIME, 97 | }, 98 | { 99 | title: '最经济模式', 100 | value: AMap.TransferPolicy.LEAST_FEE, 101 | }, 102 | { 103 | title: '最少换乘模式', 104 | value: AMap.TransferPolicy.LEAST_TRANSFER, 105 | }, 106 | { 107 | title: '最少步行模式', 108 | value: AMap.TransferPolicy.LEAST_WALK, 109 | }, 110 | { 111 | title: '最舒适模式', 112 | value: AMap.TransferPolicy.MOST_COMFORT, 113 | }, 114 | { 115 | title: '不乘地铁模式', 116 | value: AMap.TransferPolicy.NO_SUBWAY, 117 | }, 118 | ]; 119 | setTransferPolicy(tpList); 120 | 121 | const search = window.location.search; 122 | const parsed = queryString.parse(search, { arrayFormat: 'bracket', parseBooleans: true }); 123 | 124 | const { 125 | city: cityM, 126 | addressId: addressIdM, 127 | destination: destinationM, 128 | distanceRatio: distanceRatioM, 129 | showAllAddress: showAllAddressM, 130 | } = parsed; 131 | if (cityM && addressIdM && destinationM && distanceRatioM && typeof showAllAddressM === 'boolean') { 132 | setCity(cityM); 133 | setIsShowAllAddress(showAllAddressM); 134 | const pArr = []; 135 | addressIdM.split(',').forEach((addId) => { 136 | pArr.push( 137 | new Promise((res, rej) => { 138 | ps.getDetails(addId, (status, result) => { 139 | if (status === 'complete') { 140 | res(result.poiList.pois[0]); 141 | } 142 | }); 143 | }), 144 | ); 145 | }); 146 | Promise.all(pArr).then((res) => { 147 | setAddress(res); 148 | setDestination(destinationM); 149 | setDistanceRatio(Number(distanceRatioM)); 150 | notification.open({ 151 | message: '提示', 152 | description: '搜索信息已自动填入,请确认后点击“开始搜索”按钮。', 153 | icon: , 154 | }); 155 | }); 156 | } 157 | } 158 | }, [AMap]); 159 | 160 | useEffect(() => { 161 | if ($map.current) { 162 | setTimeout(() => { 163 | $map.current.setFitView(null, false, [40, 10, 310, 20]); 164 | }, 100); 165 | } 166 | }, [address]); 167 | useEffect(() => { 168 | if (address.length && destination) { 169 | setDisabled(false); 170 | } else { 171 | setDisabled(true); 172 | } 173 | }, [address, destination]); 174 | useEffect(() => { 175 | if (isShowAllAddress) { 176 | } 177 | }, [isShowAllAddress]); 178 | 179 | const [allAddress, setAllAddress] = useState([]); // 记录下所有搜到的地址 180 | 181 | const copySuccessNotification = () => { 182 | notification.open({ 183 | message: '提示', 184 | description: '已复制!', 185 | duration: 2, 186 | icon: , 187 | }); 188 | }; 189 | 190 | const startSearching = () => { 191 | tfArr.forEach((t) => { 192 | t.clear(); 193 | }); 194 | tfArr = []; 195 | setIsLoading(true); 196 | $map.current.remove(circleOverly); // 开始搜索时,清空所有圆形的覆盖物 197 | setCircleOverly([]); 198 | setIntersectionAddress([]); // 交集也清空 199 | if (address.length > 1) { 200 | const addressIdArr = []; 201 | 202 | let distanceArr = []; // 彼此之间的距离 203 | 204 | for (let i = 0; i < address.length; i++) { 205 | addressIdArr.push(address[i].id); 206 | address.slice(i + 1).forEach((ad) => { 207 | const dis = gu.distance( 208 | [address[i].location.lng, address[i].location.lat], 209 | [ad.location.lng, ad.location.lat], 210 | ); 211 | const round = Math.round(dis); 212 | distanceArr.push(round); 213 | }); 214 | } 215 | 216 | const addressIdArrValue = addressIdArr.join(','); 217 | 218 | const maxDistance = Math.max(...distanceArr); // 获取彼此之间的最大距离 219 | const promiseArr = []; // 由于搜索是异步的,所以需要在全部搜索完成后进行后续操作 220 | 221 | // PlaceSearch 的 searchNearBy 方法一次只能搜索一页最多 50 条数据,这里有可能会有大于 50 条,所以这里使用递归查询所有地点 222 | function searchNearBy(destination, ad, distance, pageIndex = 1, arr = []) { 223 | // 这里要设置 pageIndex,所以需要每个是单独实例避免污染。 224 | let ap = new AMap.PlaceSearch({ 225 | city: city, 226 | pageSize: 50, 227 | citylimit: true, 228 | }); 229 | return new Promise((res) => { 230 | ap.setPageIndex(pageIndex); 231 | ap.searchNearBy(destination, [ad.location.lng, ad.location.lat], distance, (status, result) => { 232 | if (status === 'complete') { 233 | const poiList = result.poiList; 234 | if (poiList.count > poiList.pageIndex * poiList.pageSize) { 235 | arr.push(...poiList.pois); 236 | if (poiList.pageIndex >= MAX_SEARCH_PAGE) { 237 | notification.open({ 238 | message: '提示', 239 | duration: 10, 240 | description: '目标场所的搜索结果过多,仅能展示部分结果,请填写更加详细的目标名称或缩小搜索范围。', 241 | icon: , 242 | placement: 'bottomRight', 243 | }); 244 | res(arr); 245 | } else { 246 | res(searchNearBy(destination, ad, distance, poiList.pageIndex + 1, arr)); 247 | } 248 | } else { 249 | arr.push(...poiList.pois); 250 | res(arr); 251 | } 252 | } else { 253 | res(arr); 254 | } 255 | }); 256 | }); 257 | } 258 | 259 | address.forEach((ad, index) => { 260 | promiseArr.push( 261 | new Promise((res, rej) => { 262 | searchNearBy(destination, ad, maxDistance * distanceRatio, 1, []).then((r) => { 263 | if (r && r.length) { 264 | res({ result: r, address: ad }); 265 | } 266 | }); 267 | }), 268 | ); 269 | }); 270 | 271 | Promise.all(promiseArr).then((res) => { 272 | const url = queryString.stringifyUrl( 273 | { 274 | url: window.location.pathname, 275 | query: { 276 | city, 277 | destination, 278 | distanceRatio, 279 | showAllAddress: isShowAllAddress, 280 | addressId: addressIdArrValue, 281 | }, 282 | }, 283 | { arrayFormat: 'bracket', parseBooleans: true }, 284 | ); 285 | 286 | window.history.replaceState({ url: url, title: document.title }, document.title, url); 287 | 288 | let poisInfo = []; 289 | res.forEach((r) => { 290 | // 各个用户到目标场所的路径取交集,最终在页面上呈现的即都在圆的交集处。 291 | const { result, address } = r; 292 | let circle = new AMap.Circle({ 293 | center: [address.location.lng, address.location.lat], // 圆心位置 294 | radius: maxDistance * distanceRatio, // 圆半径 295 | fillColor: '#1791fc', // 圆形填充颜色 296 | fillOpacity: 0.1, 297 | strokeColor: '#fff', // 描边颜色 298 | strokeWeight: 1, // 描边宽度 299 | }); 300 | $map.current.add(circle); 301 | setCircleOverly((oldArray) => [...oldArray, circle]); 302 | 303 | if (isShowAllAddress) { 304 | setAllAddress((o) => { 305 | const list = listDeDuplication(o, result, 'id'); 306 | return [...o, ...list]; 307 | }); 308 | } 309 | if (poisInfo.length) { 310 | poisInfo = poisInfo.filter((n) => result.some((p) => p.id === n.id)); 311 | } else { 312 | poisInfo = result; 313 | } 314 | }); 315 | 316 | setIntersectionAddress(poisInfo); 317 | setTimeout(() => { 318 | setIsLoading(false); 319 | setIsHeaderActive(false); 320 | notification.open({ 321 | message: '提示', 322 | duration: 10, 323 | description: ( 324 | 325 | 路线规划已生成,请点击地图中的绿色点标记获得详细路线规划。 326 |
327 | 分享链接已生成, 328 | 329 | 点击 330 | 331 | 复制链接。 332 |
333 | ), 334 | icon: , 335 | }); 336 | $map.current.setFitView(null, false, [40, 10, 310, 20]); 337 | }, 100); 338 | }); 339 | } else { 340 | message.error('请选择至少两个地址'); 341 | } 342 | }; 343 | 344 | const setPath = useCallback( 345 | (poi) => { 346 | if (tfArr.length) { 347 | tfArr.forEach((t) => { 348 | t.clear(); 349 | }); 350 | tfArr = []; 351 | } 352 | setCurrentRoutePlan([]); 353 | address.forEach((ad) => { 354 | const tf = new AMap.Transfer({ 355 | city: '上海市', 356 | map: $map.current, 357 | isOutline: false, 358 | autoFitView: false, 359 | policy: policy.current, 360 | }); 361 | tf.search([ad.location.lng, ad.location.lat], [poi.location.lng, poi.location.lat], (status, result) => { 362 | setCurrentSelectAddress(poi.name); 363 | setCurrentRoutePlan((o) => [ 364 | ...o, 365 | { 366 | start: ad, 367 | end: poi, 368 | result: result, 369 | tf: tf, 370 | }, 371 | ]); 372 | setIsShowFinal(true); 373 | }); 374 | tfArr.push(tf); 375 | }); 376 | }, 377 | [address, policy], 378 | ); 379 | 380 | return ( 381 |
382 | 383 |
384 |
{ 387 | setIsHeaderActive(!isHeaderActive); 388 | }} 389 | > 390 | 391 | {isHeaderActive ? '点击收起' : '点击展开'} 392 | Quick Meet 393 | { 396 | e.stopPropagation(); 397 | setIsShowInfoModal(true); 398 | }} 399 | /> 400 |
401 | { 406 | setIsShowInfoModal(false); 407 | }} 408 | footer={[]} 409 | > 410 | 你只需要输入你与你的伙伴的位置以及想要去的场所,这个网站将找到所有合适的具体位置供你们参考。 411 |
412 |
413 | 源码请查看 414 | 415 | GitHub 416 | 417 | 。 418 |
419 |
420 | 421 | 432 | 433 | 434 | 435 | { 440 | setDestination(e.target.value); 441 | }} 442 | /> 443 | 444 | 445 | {intersectionAddress.length > 0 && ( 446 | 447 | ( 451 | 452 | setHover(poi)} 454 | onMouseOut={() => setHover(null)} 455 | onClick={() => setPath(poi)} 456 | title={poi.name} 457 | description={poi.address} 458 | /> 459 | 460 | )} 461 | /> 462 | 463 | )} 464 |
465 | 466 | 467 | 468 | 选择范围倍数 469 | 472 | 搜索范围界定规则:以选择的当前位置之间的最大距离为半径的圆,在交集处选择的目标场所。默认半径为一倍大小,可选范围为 473 | [1, 2]。 474 | 475 | } 476 | trigger="click" 477 | visible={infoPopover} 478 | onVisibleChange={(v) => { 479 | setInfoPopover(v); 480 | }} 481 | > 482 | 483 | 484 | 485 | 486 | { 492 | setDistanceRatio(v); 493 | }} 494 | /> 495 | 496 | {distanceRatio} 倍 497 | 498 | 499 | 500 | 是否展示所有搜索到的地址 501 | 502 | { 507 | setIsShowAllAddress(isCheck); 508 | }} 509 | /> 510 | 511 | 512 | 513 | 522 | *调整以上的选项后需再次点击搜索 523 | 524 | {intersectionAddress.length > 0 && ( 525 | 526 | 选择公交换乘策略 527 | 528 | {transferPolicy.length > 0 && ( 529 | 544 | )} 545 | 546 | 547 | *切换策略后请重新点击绿色点标记 548 | 549 | 550 | )} 551 |
552 | 553 | {isShowAllAddress && 554 | allAddress.map((poi) => ( 555 | } 560 | label={ 561 | poi === hover 562 | ? { 563 | content: poi.name, 564 | direction: 'bottom', 565 | } 566 | : { content: '' } 567 | } 568 | zIndex={poi === hover ? 110 : 99} 569 | onMouseOver={() => setHover(poi)} 570 | onMouseOut={() => setHover(null)} 571 | /> 572 | ))} 573 | 574 | {address.map((poi) => ( 575 | setHover(poi)} 590 | onMouseOut={() => setHover(null)} 591 | /> 592 | ))} 593 | 594 | 600 |
601 | 路径规划
} 605 | onCancel={() => { 606 | setIsShowFinal(false); 607 | }} 608 | footer={[ 609 | , 617 | ]} 618 | > 619 |
620 | 目的地:{currentSelectAddress} 621 | 622 | 623 | 624 |
625 |
626 | 当前公交换乘策略:{policyTitle} 627 |
628 | 629 | {currentRoutePlan.length > 0 && ( 630 | ( 634 | 635 | 638 | 起点:{routePlan.start.name}{' '} 639 | { 641 | e.preventDefault(); 642 | ac.transferOnAMAP({ 643 | origin: routePlan.result.origin, 644 | originName: routePlan.start.name, 645 | destination: routePlan.result.destination, 646 | destinationName: routePlan.end.name, 647 | }); 648 | }} 649 | style={{ marginLeft: '10px', color: '#108ee9', fontWeight: '400', cursor: 'pointer' }} 650 | > 651 | 点击唤起高德地图客户端 652 | 653 | 654 | } 655 | description={ 656 |
657 | {routePlan.result && routePlan.result.plans && routePlan.result.plans[0] && ( 658 |
659 | 公交花费大约: 660 | 661 | ¥{routePlan.result.plans[0].cost}, {secondToDate(routePlan.result.plans[0].time)} 662 | 663 |
664 | )} 665 | {routePlan.result && routePlan.result.taxi_cost && ( 666 |
667 | 打车花费大约:¥{routePlan.result.taxi_cost} 668 |
669 | )} 670 |
671 | } 672 | /> 673 |
674 | )} 675 | /> 676 | )} 677 | 678 | 679 | ); 680 | } 681 | --------------------------------------------------------------------------------