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

功能演示

演示视频

image.png

image.png

体验地址

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,看到如下页面,项目初始化成功。
image.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
    image.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](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更新,就会触发接口调用,出现候选地点供用户选择。
image.png

2.3 引入高德API

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

2.3.1 高德开放平台
  1. 新建应用

image.png

  1. 创建key(web端使用的key)

image.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包裹。  
```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'    
}  }  

当所拖拽的地点与路线合集中最后一个地点一样时,不允许拖拽进入。
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

这样就完成啦~

源码地址

https://github.com/Ygria/travel-trace

小结

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

参考

  1. echart assets
    https://github.com/ecomfe/echarts-gl/tree/master/test/asset
  2. 全流程开发参考:
    https://www.codewithantonio.com/courses/88ee3ccc-afd7-414b-a626-e59c93847f65/chapters/b2fb3143-9683-465d-ad49-04f92011a107
  3. echarts+echarts-gl实现带有散点、路径的3d地球
    https://download.csdn.net/download/weixin_45669156/86248540?ydreferer=aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NTY2OTE1Ni9hcnRpY2xlL2RldGFpbHMvMTI1OTMyNjAx

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/582063.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

HTML5本地存储账号密码

<!DOCTYPE html> <html lang"zh-CN"> <head><meta charset"UTF-8"><meta name"viewport" content"widthdevice-width, initial-scale1.0"><title>HTML5本地存储账号密码</title> </head…

[论文笔记]SEARCHING FOR ACTIVATION FUNCTIONS

引言 今天带来另一篇激活函数论文SEARCHING FOR ACTIVATION FUNCTIONS的笔记。 作者利用自动搜索技术来发现新的激活函数。通过结合详尽的搜索和基于强化学习的搜索&#xff0c;通过实验发现最佳的激活函数 f ( x ) x ⋅ sigmoid ( β x ) f(x) x \cdot \text{sigmoid}(βx…

Android 学习 鸿蒙HarmonyOS 4.0 第二天(项目结构认识)

项目结构认识 和 了解&#xff1a; 工程目录下的结构如下&#xff1a; 首先能看到有两个.开头的文件&#xff0c;分别是.hvigor 和 .idea。这两个文件夹都是与构建有关系的&#xff0c; 如果你开发过安卓app&#xff0c;构建完会生成一个apk安装包&#xff0c;鸿蒙则是生成hap…

android 分区存储(沙盒存储)适配总结

目录 一、分区存储概念 1.外部存储分类 2.分区存储如何影响文件访问 二、分区适配方案 1. 应用分区存储的文件访问规定 (1).应用专属目录--私有目录 (2).共享目录文件--公有目录 2.MediaStore API介绍 3.Storage Access Framework介绍 三、所有文件访问权限 四、总结…

急急急!微信朋友圈删除了怎么恢复?

微信朋友圈是我们与朋友分享生活点滴的重要平台&#xff0c;但有时候微信出现异常&#xff0c;导致我们编辑好的朋友圈被删除了&#xff0c;这时候该怎么办呢&#xff1f; 幸运的是&#xff0c;微信提供了一种简单的方式来恢复已删除的朋友圈内容。微信朋友圈删除了怎么恢复&a…

react 学习笔记二:ref、状态、继承

基础知识 1、ref 创建变量时&#xff0c;需要运用到username React.createRef()&#xff0c;并将其绑定到对应的节点。在使用时需要获取当前的节点&#xff1b; 注意&#xff1a;vue直接使用里面的值&#xff0c;不需要再用this。 2、状态 组件描述某种显示情况的数据&#…

tkinter中是否有必要使用类

1. 问题背景 在使用tkinter编写事件驱动程序时&#xff0c;Fredrik Lundh的教程中提到&#xff0c;创建一个类&#xff08;App&#xff09;作为框架&#xff0c;并以类的实例运行程序&#xff0c;这样会更好&#xff0c;而不是直接启动程序。 以下是问题&#xff1a; 在tkin…

【Go语言快速上手(五)】文件操作协程操作

&#x1f493;博主CSDN主页:杭电码农-NEO&#x1f493;   ⏩专栏分类:Go语言专栏⏪   &#x1f69a;代码仓库:NEO的学习日记&#x1f69a;   &#x1f339;关注我&#x1faf5;带你学习更多Go语言知识   &#x1f51d;&#x1f51d; GO快速上手 1. 前言2. GO语言的文件操…

武汉星起航:挂牌上海股权中心,开启资本新篇章助力跨境电商飞跃

2023年10月30日&#xff0c;武汉星起航电子商务有限公司在上海股权托管交易中心成功挂牌展示&#xff0c;标志着这家在跨境电商领域拥有卓越声誉的企业正式迈入了资本市场的大门。对于武汉星起航来说&#xff0c;这不仅是其发展历程中的一个重要里程碑&#xff0c;更是对其业务…

Python学习笔记------异常

当检测到错误时&#xff0c;Python解释器就无法继续执行了&#xff0c;反而出现一些错误提示&#xff0c;就是所谓异常&#xff08;bug&#xff09; 1、异常的捕获方法 任何程序运行过程中都可能出现异常&#xff0c;我们可以在力所能及的范围内&#xff0c;对可能出现的bug提…

C语言趣味代码(四)

这一篇主要编写几个打字练习的小程序&#xff0c;然后通过这些小程序的实现来回顾复习我们之前学过的知识&#xff0c;然后通过这写打字练习的小程序来提升我们的打字技术和编程技术。 1. 打字练习 1.1 基本打字练习 1.1.1 基本实现 首先我们来制作一个用于计算并显示输入一…

ip网络广播前置放大器SV-7031 接纯后级功放

ip网络广播前置放大器SV-7031 接纯后级功放 感谢您使用我们的网络前置放大器SV-7031。 SV-7031具有1路AC200V OUT电源输出&#xff0c;1路AC200V IN电源输入&#xff1b;RJ45 网络接口&#xff0c;具有网络音频广播、本地广播功能。 18123651365 ip网络广播前置放大器SV-703…

vue-cli+vue3+vite+ts 搭建uniapp项目全过程(一)

unapp官方提供了cli 脚手架创建 uni-app 项目的文档 Vue3/Vite版要求 node 版本 18、20使用Vue3/Vite版创建不会提示选择模板&#xff0c;目前只支持创建默认模板 本文以vue3vitets为例 1、初始化项目 npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project 执行完生成…

就业班 第三阶段(tomcat) 2401--4.28 day1 tomcat1安装配置及单机多实例

企业 Tomcat 运维 文章目录 企业 Tomcat 运维一、Tomcat 简介1、Tomcat好帮手---JDK2、安装Tomcat & JDK1、系统环境说明2 、安装JDK3、安装Tomcat 二、Tomcat目录介绍1、tomcat主目录介绍2、webapps目录介绍3、Tomcat配置介绍&#xff08;conf&#xff09;4、Tomcat的管理…

Linux-管道通信

1. 管道概念 管道&#xff0c;是进程间通信的一种方式&#xff0c;在Linux命令中“ | ”就是一种管道&#xff0c;它可以&#xff0c;连接前一条命令&#xff0c;和后一条命令&#xff0c;把前面命令处理完的内容交给后面&#xff0c;例如 cat filename | grep hello …

本地部署 Meta Llama3-8b

Meta Llama 3(8B) Instruct model performance Meta Llama 3(8B) Pre-trained model performance 使用 Ollama 运行 Llama3 访问 Tags llama3&#xff0c;选择你想运行的模型&#xff0c;例如&#xff0c;8b-instruct-q8_0 拷贝并运行命令&#xff0c;ollama run llama3:8b-…

秋招后端开发面试题 - Java语言基础(上)

目录 Java基础上前言面试题Java 语言的特点JVM JDK JRE什么是跨平台性&#xff1f;原理是什么&#xff1f;什么是字节码?采用字节码的好处是什么?Java 和 C 的区别&#xff1f;注释&#xff1f;关键字关键字 instanceof类型转换关键字 this 和 super关键字 final finally fin…

Android数据恢复:如何在手机上恢复丢失的文件和照片

我们都有 我们错误地从手机中删除重要内容的时刻。确实如此 不一定是我们的错。其他人可以对您的手机数据执行此操作 有意或无意。这在某个时间点发生在我们所有人身上。 但是&#xff0c;今天市场上有各种各样的软件可以 帮助恢复已删除的文件。这些类型的软件被归类为数据恢复…

Linux命令大全 以及搭建hadoop

Liunx系统目录 ├── bin -> usr/bin # 用于存放二进制命令 ├── boot # 内核及引导系统程序所在的目录 ├── dev # 所有设备文件的目录&#xff08;如磁盘、光驱等&#xff09; ├── etc # 配置文件默认路径、服务启动命令存放目录 ├── home # 用户家目录&#…

UEFI安全启动模式下安装Ubuntu的NVIDIA显卡驱动

UEFI安全启动模式下安装ubuntu的nvidia显卡驱动 实践设备&#xff1a;华硕FX-PRO&#xff08;NVIDIA GeForce GTX 960M&#xff09; 一、NVIDIA官网下载驱动 1.1在浏览器地址栏输入https://www.nvidia.cn/drivers/lookup/进入网站&#xff0c;接着手动驱动搜索&#xff0c;并…