功能演示
演示视频
体验地址
Vercel App
开发技术栈:
- NextJs(前端框架)
- React(前端框架)
- TailwindCSS (CSS样式)
- echart + echart gl (地图生成)
- shadui(UI组件库)
- Zustand
- lucide-react (图标)
第三方:
- Convex(数据存储+接口)
- Vercel(项目托管)
- 高德开放平台(提供地图编码、逆编码等WEB API)
开发流程
下面给出关键步骤及部分代码。
1. Setup
1.1 初始化NextJS项目
系统要求:Nodejs 18.17+
打开终端,在控制台执行:
npx create-next-app@latest```
全部选择默认选项即可。
![image.png](https://cdn.jsdelivr.net/gh/Ygria/Pictures@main/20240425232407.png)
初始化完成后,进入项目并运行。
```bash
cd travel-tracel
npm run dev
打开localhost:3000,看到如下页面,项目初始化成功。
1.2 安装npm依赖
安装一些主要的依赖。后续需要用到的依赖可以边开发边安装。
# 安装echarts依赖
npm install echarts-for-react
npm install echarts-gl
npm install require # 图标组件
npm install lucide-react
2 定义标记点
实现输入地点或地点关键字,查询经纬度,从而完成标记点的定义。
实现思路:
- 首先查询Convex数据库,提供10个最匹配的候选项
- 若没有找到对应候选项,用户可以点击搜索按钮,会调用高德API进行查询。
- 如果仍然没有查询到,可以点击“经纬度”按钮,进行经纬度的自行填写。
- 已经添加了的标记点,支持编辑和删除。
- 使用Zustand,进行地点增、删、改等状态管理。
2.1 定义useLocationPoints store
import {create} from "zustand"; import {nanoid} from "nanoid"; interface ILocationPoints {
locations: LocationPoint[],
addLocation: (loc: LocationPoint) => void,
editLocation: (loc: LocationPoint) => void,
removeLocation: (id: string) => void } interface LocationPoint{
id: string,
name: string,
lng: string,
lat: string }
export const useLocationPoints = create<ILocationPoints>(set => ({
locations: [] as LocationPoint[],
addLocation: (loc: LocationPoint) => {
const _id = nanoid()
loc["id"] = _id
set(state => ({
locations: [...state.locations, loc]
}));
},
editLocation: (loc: LocationPoint)=>{
set(state => ({
locations: state.locations.map(item=>{
if(item.id === loc.id){
return {
...loc,
id: item.id,
}
}else {
return item
}
})
}));
},
// 移除location
removeLocation:(id:string)=>{
set(state => ({
locations: state.locations.filter(item=>item.id !== id)
}));
} }));
对于location的增删改,均依赖于以上store实现。单页面定义React自带的useState当然也可以,但是为了便利于组件拆分,所以使用store,不用再跨组件管理状态的提交、更新、监听等,方便了很多。
考虑到location的名称、经纬度均有可能更改,所以使用nanoid生成唯一id进行location的索引。
2.2 引入Convex
使用convex作为平台后台。convex可以提供数据库存储、RESTful接口及接口调试的功能。
2.2.1 Convex 项目配置
-
访问
Convex
-
创建app
-
在nextjs项目中,进行相应的配置。
参考Convex官方文档:
Next.js Quickstart | Convex Developer Hub
# 进入项目目录后,安装
cd my-app && npm install convex
npx convex dev
因为我已经在Convex控制台中创建过app了,所以选择已存在的project
2.2.2 Table Schema定义和数据初始化
在Convex目录下,新建·文件schema.ts
import { v } from "convex/values";
import {defineSchema, defineTable} from "convex/server";
export default defineSchema({
locations: defineTable({ // 经纬度
name: v.string(), // 经度
lng: v.number(), // 纬度
lat:v.number(),
}) .index("by_name",["name"]) .searchIndex("search_by_name",{ searchField: "name", filterFields: ["name"] }),});
如上所示,定义了一个名称为“locations”的数据表,有name、lng、lat三个字段,并定义了查询的规则(by_name)
运行npx convex dev
后,会发现Convex控制台中已经生成了该表。
我从网上找到了一些世界范围内的经纬度数据,是csv格式。通过python处理成出初始化数据。
小技巧:对于不同格式的csv,在第一行定义与字段相匹配的表头即可快速处理。
import json
import pandas as pd
data = [] csv_data = pd.read_csv('globalcities.csv',header=0,encoding="utf-8")
# {"name": "上海", "lng": 121.47,"lat":31.23} with open("global.csv","w",encoding='utf-8') as file:
for index, row in csv_data.iterrows():
d = {
"name": row["城市名中文"] ,
"lng":row["经度"],
"lat":row["纬度"]
}
file.write(json.dumps(d,ensure_ascii=False) )
print(json)
执行导入:
npx convex import --table locations convex/init.jsonl```
如下图所示,数据导入完成!
![image.png](https://cdn.jsdelivr.net/gh/Ygria/Pictures@main/20240425220013.png)
#### 2.2.3 查询接口定义和使用
在convex文件夹下,新建文件location.ts,定义一个get查询接口
```javascript
import { v } from "convex/values"; import { query } from "./_generated/server"
export const get = query({
args:{
// orgId: v.string(),
// search: v.optional(v.string()), // favorites: v.optional(v.string()) name: v.string()
},
handler: async (ctx,args) => {
let locations = [];
locations = await ctx.db.query("locations")
.withSearchIndex("search_by_name", (q) =>
q.search("name", args.name)).take(10);
return locations;
} })
运行npx convex dev
进行接口的生成。
接口使用:(value为useState定义的动态值,绑定地点input输入框)
const queryResult = useQuery(api.locations.get, {name: value});
在模版代码中遍历查询结果:
{
queryResult?.map(res => (
<Badge variant="outline" key = {res._id} onClick={event => handleClick(event, res)}>
{res.name } [<span className = "text-red-300">{res.lng}</span>,<span className = "text-green-800">{res.lat}</span>]
</Badge>
)) }
实现效果如下图所示,输入内容后value更新,就会触发接口调用,出现候选地点供用户选择。
2.3 引入高德API
由于上一步的地点不一定全,也由于限定了仅显示前10条,故而又引入高德api进行查询。(有局限:无法查询国外地名)
2.3.1 高德开放平台
- 新建应用
- 创建key(web端使用的key)
使用该配置好的key就可以调用高德的接口了,每天有免费五千次的额度,对于一个小demo完全够用了。
2.3.2 调用高德接口
gaode.ts
// 调用接口
interface GaodeRes {
formatted_address: string,
location: string }
export const getGeoCode = async (address: string) : Promise<GaodeRes[]> => {
let result = await fetch(`https://restapi.amap.com/v3/geocode/geo?address=${address}&key={}`,{
headers: {
Accept: 'application/vnd.dpexpo.v1+json' //设置请求头
},
method: 'get',
})
let res = await result.json() //必须通过此方法才可返回数据
return res.geocodes; }
该接口设定为点击查询按钮时才触发(节约次数)。
const [gaodeQueryResult,setGaodeQueryResult] = useState([]);
const searchGeoCode = () =>{
let queryResult = getGeoCode(value);
queryResult.then(res=>{
if(res && res.length > 0){
let data = res.map(item=>{
return {
"name": item.formatted_address,
"lng": item.location.split(",")[0],
"lat": item.location.split(",")[1],
}
})
setGaodeQueryResult(data)
}else{
setGaodeQueryResult([])
toast.error("未能查询到该地点!您可以通过经纬度进行查询。")
}
})
}
同样将结果在模版代码中遍历展示即可。
2.4 通过经纬度增加
如果查询不到,实现了点击“经纬度”展开,自行输入经纬度定义标记点的功能。值得一提的是使用了ShadUI的InputOTP组件,可以规定输入的位数和正则,我规定了只可以输入负号、小数点和数字。
2.5 标记点的删除和编辑
- 悬停状态才显示编辑和删除按钮。
<div className = "flex gap-x-2 m-2 relative group" ref={drag}
style={{
opacity: isDragging ? 0.5 : 1,
}}><MapPin />{name}
<button className = "opacity-0 group-hover:opacity-100" onClick={()=>onOpen(id,name,lng,lat)} ><Pencil size = "16"></Pencil> </button>
<button className = "opacity-0 group-hover:opacity-100" onClick={onRemove} ><X size = "16"></X> </button> </div>
- 定义useEditModal,控制编辑Modal的显示和方法。
import {create} from "zustand"; const defaultValues = {
name: "",
lng: "",
lat: "",
id: "" };
interface IEditModal {
isOpen: boolean;
initialValues: typeof defaultValues;
onOpen: (id:string,name:string,lng: string,lat: string) =>void;
onClose: () => void; }
export const useEditModal = create<IEditModal>((set) =>({
isOpen: false,
onOpen:(id:string,name,lng,lat)=>set({
isOpen:true,
initialValues: {id,name,lng,lat}
}),
onClose: ()=>set({
isOpen: false,
initialValues: defaultValues
}),
initialValues: defaultValues
}))
2.2 定义路线(react dnd)
用户可以拖拽地点到虚线框内,形成路线。路线图的增删改同样适用zustand实现,不加赘述。拖动点到路线框中形成路线,使用了react drag and drop库完成。
npm install react-dnd```
引入react dnd后,将使用到拖拽的部分使用如下provider包裹。
```html
<DndProvider backend={HTML5Backend}></DndProvider>
参考官方示例写法,拖部分:(location.tsx)
import { useDrag } from 'react-dnd'
const [{ isDragging, }, drag, preview] = useDrag(
() => ({
type: ItemTypes.Location,
item: { name: name,id:id } ,
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
}),
[], )
放部分:
import {Overlay, OverlayType} from "@/app/components/Overlay";
import { useDrop} from 'react-dnd'
const [{ isOver,canDrop }, drop] = useDrop(
() => ({
accept: ItemTypes.Location,
canDrop: (item:{name: string,id:string}) => {
if(!lineData || lineData.length == 0){
return true
}else{
return lineData[lineData.length - 1]?.id !== item.id
}
},
drop: (item:{name: string,id:string}) => {
dropLocation(id, item)
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}),
[lineData], )
在模版代码中,增加了Overlay并根据是否可以放的状态,给不同的颜色。
{isOver && !canDrop && <Overlay type={OverlayType.IllegalMoveHover} />} {!isOver && canDrop && <Overlay type={OverlayType.PossibleMove} />} {isOver && canDrop && <Overlay type={OverlayType.LegalMoveHover} />}
Overlay.js
export var OverlayType ;(function (OverlayType) {
OverlayType['IllegalMoveHover'] = 'Illegal'
OverlayType['LegalMoveHover'] = 'Legal'
OverlayType['PossibleMove'] = 'Possible' })(OverlayType || (OverlayType = {})) export const Overlay = ({ type }) => {
const color = getOverlayColor(type)
return (
<div
className="overlay"
role={type}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
zIndex: 1,
opacity: 0.5,
backgroundColor: color,
}}
/>
) } function getOverlayColor(type) {
switch (type) {
case OverlayType.IllegalMoveHover:
return 'red'
case OverlayType.LegalMoveHover:
return 'green'
case OverlayType.PossibleMove:
return '#66CC66'
} }
当所拖拽的地点与路线合集中最后一个地点一样时,不允许拖拽进入。
3 地图渲染(echartgl)
使用react-echart,并导入echart-gl,实现3D地图渲染。
<ReactEcharts
option={options}
style={{ width: "900px", height: "800px" }}
></ReactEcharts>
const [options,setOptions] = useState({
backgroundColor: "#000",
globe: {
baseTexture:"/earth1.jpg",
shading: "lambert",
atmosphere: {
// 不需要大气光圈去掉即
show: false,
offset: 4, // 大气层光圈宽度
},
viewControl: {
distance: 200, // 默认视角距离地球表面距离
},
light: {
ambient: {
intensity: 1, // 全局的环境光设置
},
main: {
intensity: 1, // 场景主光源设置
},
},
},
})
使用useState,根据lineCollection、location数据,动态地增、减地图options。
useEffect(() => {
let series = initSeries;
series[0].data = normalData(lines);
series[1].data = activeData(lines);
locations.forEach((item) => {
series[2].data.push({
name: item.name,
value: [item.lng,item.lat]
});
});
setOptions({
...options,
...customTheme,
series: series
})
}, [locations,lineCollections,customTheme]);
3.1 地球换皮肤
更换贴图,即可实现地球换皮肤。
从网上搜罗一些地球贴图,放入public目录即可。
const themeTopics = [{
globe: {
baseTexture: "/earth1.jpg",
}, },
{
globe: {
baseTexture: "/earth2.jpg",
},
},
{
globe: {
baseTexture: "/earth3.jpg",
},
},
{
globe: {
baseTexture: "/earth4.jpg",
},
} ]
部署
使用vercel作为部署托管。进入vercel并授权github项目,配置NextJS项目的构建命令。
由于我在github的项目源码没有放在根目录,所以还需要设置root-directory。
将所使用到的环境变量放在environment-variables中。
需要注意的是,Convex需要生成部署生产使用的URL和KEY,并配到环境变量中。
这样就完成啦~
源码地址
https://github.com/Ygria/travel-trace
小结
写的第一个相对完整的react小项目,麻雀虽小五脏俱全。使用合理的开源组件让全栈变得非常容易。
只使用到了react useState和useEffect两个hooks,已经感觉到了一定的理解门槛,与vue的将许多状态处理都放在内部封装好相比,react很多时候需要你自己来理解状态的依赖关系然后处理。react的tsx函数式写法的确很方便(比vue的defineComponents好多了……)。期待随着学习深入,了解到更多有趣的东西。
参考
- echart assets
https://github.com/ecomfe/echarts-gl/tree/master/test/asset
- 全流程开发参考:
https://www.codewithantonio.com/courses/88ee3ccc-afd7-414b-a626-e59c93847f65/chapters/b2fb3143-9683-465d-ad49-04f92011a107
- echarts+echarts-gl实现带有散点、路径的3d地球
https://download.csdn.net/download/weixin_45669156/86248540?ydreferer=aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NTY2OTE1Ni9hcnRpY2xlL2RldGFpbHMvMTI1OTMyNjAx