全栈从0到1:3D旅游地图标记和轨迹生成

1,298 阅读8分钟

功能演示

演示视频

image.png

image.png

体验地址

Vercel App travel-trace.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

全部选择默认选项即可。 20240419003314.png 初始化完成后,进入项目并运行。

cd travel-tracel
npm run dev

打开localhost:3000,看到如下页面,项目初始化成功。 20240419004550.png

1.2 安装npm依赖

安装一些主要的依赖。后续需要用到的依赖可以边开发边安装。

# 安装echarts依赖
npm install echarts-for-react
npm install echarts-gl
npm install require 
# 图标组件
npm install lucide-react

2 定义标记点

实现输入地点或地点关键字,查询经纬度,从而完成标记点的定义。 实现思路:

  1. 首先查询Convex数据库,提供10个最匹配的候选项
  2. 若没有找到对应候选项,用户可以点击搜索按钮,会调用高德API进行查询。
  3. 如果仍然没有查询到,可以点击“经纬度”按钮,进行经纬度的自行填写。
  4. 已经添加了的标记点,支持编辑和删除。
  5. 使用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 项目配置

  1. 访问Convex

  2. 创建app 20240420103331.png

  3. 在nextjs项目中,进行相应的配置。 参考Convex官方文档: Next.js Quickstart | Convex Developer Hub

# 进入项目目录后,安装
cd my-app && npm install convex
npx convex dev

因为我已经在Convex控制台中创建过app了,所以选择已存在的project

image.png

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

2.2.3 查询接口定义和使用

在convex文件夹下,新建文件location.ts,定义一个get查询接口

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更新,就会触发接口调用,出现候选地点供用户选择。 image.png

2.3 引入高德API

由于上一步的地点不一定全,也由于限定了仅显示前10条,故而又引入高德api进行查询。(有局限:无法查询国外地名)

2.3.1 高德开放平台

  1. 新建应用 20240420095130 (1).png

  2. 创建key(web端使用的key) 20240420095503.png 使用该配置好的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组件,可以规定输入的位数和正则,我规定了只可以输入负号、小数点和数字。

image.png

2.5 标记点的删除和编辑

  1. 悬停状态才显示编辑和删除按钮。
<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>
  1. 定义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  
  
}))

image.png

2.2 定义路线(react dnd)

用户可以拖拽地点到虚线框内,形成路线。路线图的增删改同样适用zustand实现,不加赘述。拖动点到路线框中形成路线,使用了react drag and drop库完成。

npm install react-dnd

引入react dnd后,将使用到拖拽的部分使用如下provider包裹。

<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'  
    }  
}

当所拖拽的地点与路线合集中最后一个地点一样时,不允许拖拽进入。 image.png

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目录即可。 image.png

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。

image.png 将所使用到的环境变量放在environment-variables中。 image.png

image.png 需要注意的是,Convex需要生成部署生产使用的URL和KEY,并配到环境变量中。 image.png

这样就完成啦~

源码地址

github.com/Ygria/trave…

小结

写的第一个相对完整的react小项目,麻雀虽小五脏俱全。使用合理的开源组件让全栈变得非常容易。 只使用到了react useState和useEffect两个hooks,已经感觉到了一定的理解门槛,与vue的将许多状态处理都放在内部封装好相比,react很多时候需要你自己来理解状态的依赖关系然后处理。react的tsx函数式写法的确很方便(比vue的defineComponents好多了……)。期待随着学习深入,了解到更多有趣的东西。

参考

  1. echart assets github.com/ecomfe/echa…
  2. 全流程开发参考: www.codewithantonio.com/courses/88e…
  3. echarts+echarts-gl实现带有散点、路径的3d地球 download.csdn.net/download/we…