@orochitian/map-3d
v0.1.0
Published
基于 Three.js 的 3D 立体行政区划地图,传入 GeoJSON 与少量配置即可生成一张可交互的科技风立体地图
Maintainers
Readme
three-geo-map3d
基于 Three.js 的 3D 立体行政区划地图。传入一份 GeoJSON 与少量配置,就能得到一张带立体挤出、
地形明暗、发光描边、悬浮高亮、标注碰撞剔除、雷达扫描与流光的科技风地图,
另有一条可选的边缘电流(borderFlow.show,默认关闭)。
框架无关:内部只依赖 three 与 gsap,在 Vue / React / 原生 JS 里都是同一套命令式 API。
import { createGeoMap3D } from 'three-geo-map3d'
const map = createGeoMap3D({
container: '#map',
data: '/geo/china.json'
})安装
npm i three-geo-map3d three gsapthree 与 gsap 是 peerDependencies,由你的项目提供 —— 同一页面出现两份 three 会导致
材质与场景互不认识,所以不打进产物。要求 three >= 0.150、gsap >= 3.11。
包只发布 ESM(内部用到 three 的 addons/,那些文件本身只有 ESM 版本)。
容器要求
容器需要有确定的宽高,其余的包会自己处理(定位上下文、overflow、尺寸变化监听)。
<div id="map" style="width: 100%; height: 600px"></div>背景默认透明,交给页面。想让包顺便刷个底色就配 colors.background。
配置项
只有 container 与 data 是必填,其余都有默认值。
数据
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| container | string \| HTMLElement | — | CSS 选择器或 DOM 元素 |
| data | object \| string \| Promise \| Function | — | GeoJSON 对象、URL、Promise,或返回它们的函数 |
| fields | { id, name } | {} | 从 feature.properties 取标识与名称的字段名。默认依次尝试 adcode / id / code 与 name |
| fetchTimeout | number | 8000 | data 为 URL 时的请求超时(毫秒) |
data 支持 Polygon 与 MultiPolygon;畸形环(点数不足、含非法坐标)会被跳过而不是让整张图失败。
配色
theme 选一套内置配色作为基底,colors 再逐项覆盖。
createGeoMap3D({
container: '#map',
data: geoJson,
theme: 'violet',
colors: {
highlight: '#ffe08a',
radar: '#ff4d8d'
}
})内置主题:tech(默认,科技蓝)、mint(青绿)、violet(紫罗兰)、amber(琥珀)。
| colors 字段 | 说明 |
| --- | --- |
| mapTop | 顶面基色,画面里面积最大的一块,决定整幅地图的色调 |
| mapSide / mapSideDeep | 侧壁渐变的上沿与根部。两者亮度差决定立体块「有多厚」 |
| mapBottom | 底座渐变的最暗端,取接近页面背景的颜色才像影子 |
| outline | 顶面描边。比顶面亮一截,边界才能挑出来 |
| glow / glowDeep | 发光色与其暗阶:流光、光柱、镜面高光 / 投影光环 |
| radar | 雷达扫描扇面颜色,各内置主题都有对应取值。与 glowDeep 分开,调扫描色不会连带改掉投影光环 |
| borderFlow | 边缘电流色。电流经过处会盖掉底下的描边,所以任意色相都能读出来(红、品红都行) |
| highlight | 悬浮高亮色。建议与主色拉开色相 |
| light | 光照颜色,一般不用改 |
| background | 容器背景色,不给则保持透明 |
| labelText / labelGlow | 标注文字色与外发光 |
| tooltipText / tooltipBg / tooltipBorder | 悬浮提示配色 |
相机与尺度
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| size | number | 64 | 场景尺度:数据主体的较长边占多少场景单位。其余尺寸(挤出深度、光柱、雷达)都按它推导,一般不用改 |
| projection.center | [lon, lat] | 主体范围中心 | 指定投影中心 |
| projection.scale | number | 由 size 推导 | 指定缩放(场景单位 / 弧度),语义同 d3-geo |
| camera.pitch | number | 43 | 俯仰角(与水平面夹角,度) |
| camera.yaw | number | 0 | 方位角(度),0 为从正南方看 |
| camera.zoom | number | 1 | 取景缩放,>1 拉近 |
| camera.fov | number | 45 | 垂直视场角 |
| camera.distance | number | 自动 | 显式指定相机距离,给了就不再自动取景 |
| camera.margin | number | 1.12 | 自动取景的留白系数,1 表示轮廓刚好贴边 |
| camera.autoFitOnResize | boolean | false | 容器尺寸变化时是否重新取景(默认不动,避免把使用者已经调好的视角拽回去) |
自动取景会排除零散离岛:按多边形面积从大到小累加到 99.9% 为止,只用这部分决定缩放、 中心与相机距离。中国数据里的南海诸岛就属于被排除的部分 —— 否则整幅地图会被压到画面中央 一小块。被排除的区域照常渲染,只是不参与「画面要装下多少」的判断。
立体块
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| extrude.depth | number | size / 16 | 挤出深度基准 |
| extrude.levelRange | [number, number] | [0.82, 1.38] | 高度倍数区间,按标识哈希稳定取值。设成 [1, 1] 则全部等高 |
| extrude.resolveLevel | (properties, id) => number | — | 自定义高度倍数,按业务指标定高低 |
| outline.show | boolean | true | 顶面描边与底部光环 |
| outline.shadow | boolean | true | 只关底部投影光环 |
| basePlate.show | boolean | true | 底座 |
| basePlate.depth | number | 挤出深度的 0.6 | 底座厚度 |
高度用确定性哈希而不是随机数:同一份数据每次刷新都是同样的高低错落, 否则每次进来都换一副样子,看的人会以为数据变了。
标注与特效
业务数据的画法(光柱、飞线、气泡、数值…)不在配置里,走自定义渲染。
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| label.show | boolean | true | 区域名称标注 |
| label.shorten | boolean | true | 裁剪中文行政层级后缀(省 / 市 / 自治区…) |
| label.format | (name, properties) => string | — | 自定义标注文本 |
| label.collision | boolean | true | 碰撞剔除:压盖时保留区域大的那个 |
| label.scaleRange | [number, number] | [0.6, 2.4] | 标注随相机距离缩放的区间 |
| label.fontSize | string | 12px | 标注字号 |
| radar.show | boolean | true | 雷达扫描扇面。颜色见 colors.radar |
| radar.radius | number | size × 0.72 | 扇面半径 |
| radar.cycleDuration | number | 6000 | 转满一圈的毫秒数 |
| sweep.show | boolean | true | 流光(一条亮带偶尔斜掠过地图) |
| borderFlow.show | boolean | false | 边缘电流(一条带长拖尾的亮带沿整幅地图的最外圈绕行)。默认关闭,颜色见 colors.borderFlow,速度见 animation.borderFlow.cycleDuration |
雷达扇面沉在地图底面之下,被立体块挡住,只在轮廓之外可见,不会盖住地图本身。它的强度渐变
写在顶点 alpha 上(前缘最强、向后缘与内外两端淡出),因此在透明画布上不会把页面背景遮暗一块,
色相由 colors.radar 完全决定。
边缘电流默认不开,写 borderFlow: { show: true } 才有:它是一条不停绕行的高亮动效,
放在信息密度高的看板里容易抢视线,属于按需打开的点睛效果,而不是地图的基础表现。
关闭时这条线根本不会被创建,也不会有补间在跑,没有任何开销。
开启后全图只有一条,跑在整幅地图的最外圈上(与顶面描边同一条路径、同一高度),
内部的区块界线上不跑。它与 outline.show 各自独立:只要电流不要静态描边、或反过来,都成立。
最外圈是由所有区块的外环相消求出来的,不需要多边形裁剪库:相邻两块共享的界线在两块的环里 各出现一次,把所有边按重数统计,只出现一次的就是并集边界,出现两次的内部界线正好互相消掉。 串成的若干闭环里取面积最大的那个 —— 用面积而不是周长,因为海岸线曲折的小岛完全可能比一段 平直的大陆边界更长。多岛地区(比如福州的平潭、连江外海诸岛)的岛屿轮廓上不会有电流。
这条路依赖「相邻区块的共享顶点严格一致」。行政区划数据基本都是从同一份拓扑切出来的, 这个前提成立;几块数据来自不同来源拼接、或坐标被重新取整过的话,内部界线不再成对出现, 求出来的环会捎上一段内部界线。这类数据本来也拼不出一张边界严丝合缝的地图,包里不为它兜底。
电流的高度逐顶点取所在区块的描边高度。区块高低不等,最外圈会横跨若干区块, 这样电流才始终贴着实际的顶面边缘走,跨区块处的落差摊在一条边长不到一个场景单位的边上。
拖尾给得比较长:可见段约 70 个场景单位,占最外圈周长两成出头。1px 的线宽下短促的小亮点 在曲折的海岸线上只会读作噪点,得有这个量级的长尾才抓得住眼睛。
亮度是恒定的,刻意不做搏动 / 呼吸一类的周期起伏 —— 电流本身在动,再叠一层亮度变化 会盖过「在移动」这个主要信息,读作边界在闪而不是电流在流。
交互
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| interaction.hover | boolean | true | 悬浮高亮与抬升 |
| interaction.tooltip | boolean | true | 悬浮提示 |
| interaction.tooltipFormat | (userData) => string | — | 自定义提示内容(返回 HTML 字符串) |
| interaction.click | boolean | true | 派发 click 事件 |
| interaction.orbit | boolean | true | 是否创建 OrbitControls |
| interaction.rotate / zoom / pan | boolean | true | 左键旋转 / 滚轮缩放 / 右键平移 |
| interaction.pitchRange | [number, number] | [15, 70] | 可拖动的俯仰角范围(度) |
| interaction.zoomRange | [number, number] | [0.5, 1.6] | 相对取景距离的缩放范围 |
点击与「拖着转视角」是分开判定的:按下到抬起位移超过 5px 或间隔超过 500ms 就算相机操作,
不会误触发 click。平移有边界,拖到边界时画面里一定还留着大半张地图。
动画
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| animation.enter | false \| { duration, cameraDistanceRatio } | { 1500, 2.5 } | 入场动画,false 则直接呈现终态 |
| animation.cameraReset.duration | number | 800 | resetCamera() 的补间时长 |
| animation.hover | { duration, liftRatio } | { 200, 0.7 } | 悬浮抬升时长与抬升量(挤出深度的倍数) |
| animation.radar.cycleDuration | number | 6000 | 同 radar.cycleDuration |
| animation.sweep | { duration, minInterval, maxInterval, fadeRatio, strength } | 见下 | 流光参数 |
| animation.borderFlow | { cycleDuration, strength } | { 20000, 0.9 } | 边缘电流绕一圈的毫秒数与亮度,需先开 borderFlow.show |
| animation.reducedMotion | 'auto' \| boolean | 'auto' | 默认跟随系统的 prefers-reduced-motion:reduce 时不播入场、不转雷达、不掠流光、不跑电流 |
流光的间隔取的是区间随机值(默认 1–3 秒),不是定值 —— 等间隔的循环会让人一眼看出周期。
animation.borderFlow.cycleDuration 是电流绕完最外圈一整圈的时间,改小即整体变快。
默认 20 秒是按「地级市 + 曲折海岸线」估的(最外圈周长通常在 4~5 倍地图宽度上下);
换成边界长得多的数据(全国带海岸线)时,同样的时长线速度会明显偏快,按需调长。
animation.borderFlow.strength 是电流的亮度/覆盖强度,同时决定拖尾有多少段是不透明的。
其他
| 配置 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| alpha | boolean | true | 画布是否透明 |
| antialias | boolean | true | 抗锯齿 |
| maxPixelRatio | number | 2 | 像素比上限。再高对观感几乎没有增益,填充率成本却是平方级的 |
| resizeDebounce | number | 200 | 尺寸变化防抖(毫秒) |
| injectStyle | boolean | true | 是否自动注入标注 / 提示的样式 |
| terrain | { relief, satellite, bounds } | — | 地形底图,见下文 |
实例方法
const map = createGeoMap3D({ /* … */ })
map.setData(otherGeoJson) // 换数据:重新取景 + 重建 + 重播入场
map.setColors({ mapTop: '#1f6fd0' })
map.setView({ pitch: 60, yaw: 30, zoom: 1.2 })
map.getView() // { pitch, yaw, zoom, distance }
map.resetCamera() // 「全景视图」:补间回入场终态
map.resize() // 手动同步尺寸(传 true 则重新取景)
map.clearHover()
map.getStatus() // loading | ready | empty | error | disposed
map.dispose() // 释放全部资源setColors 的说明:顶面、描边、光环、雷达、高亮、流光与边缘电流即时生效;侧壁与底座的渐变
写在顶点色里,要等下次 setData() 才完整生效。换主题时顺手重建一次即可。
自定义渲染
包只负责底图本身(立体块、描边、标注、雷达、流光、边缘电流)。业务数据怎么画由你决定 —— 柱状、飞线、涟漪、图标、数值气泡,每个项目的要求都不一样,包里塞一种实现只会两头不讨好。
包提供的是接缝:定位、跟随、清理这三件麻烦事它做,画什么你说。
map.addObject(object3d, { coordinate, height, attach, raycast }) // 挂 three 对象
map.removeObject(object3d)
map.addMarker(htmlOrElement, { coordinate, height, interactive }) // 挂 HTML 标记
map.removeMarker(marker)
map.onFrame((time, delta) => {}) // 每帧钩子,返回注销函数
map.getRegions() // 各区块的位置与高度挂 three 对象
给了 coordinate 就由包算好位置,你不用关心内部坐标系:
import { CylinderGeometry, Mesh, MeshBasicMaterial } from 'three'
const geometry = new CylinderGeometry(0.16, 0.16, 6, 8, 1, true)
const material = new MeshBasicMaterial({ color: '#29d3ff', transparent: true, opacity: 0.85 })
const pillar = new Mesh(geometry, material)
pillar.rotation.x = Math.PI / 2 // 圆柱默认沿 +Y,地图的「上」是局部 +Z
// height 是相对所在区块顶面的偏移,传柱心高度,底端就正好落在顶面上
map.addObject(pillar, { coordinate: [116.4074, 39.9042], height: 3 })挂载点:为什么默认贴在区块顶面上
attach 有三种取值,给了 coordinate 时默认 'region':
| 取值 | 挂载到 | height 的含义 | 适合 |
| --- | --- | --- | --- |
| region(默认) | 所在区块的 mesh | 相对该区块顶面的偏移 | 落在某个区域内的点位标记 |
| map | 区块组 | 绝对高度 | 跨区块的东西:飞线、范围圈 |
| scene | 场景根 | 绝对高度 | 不随地图变换的装饰 |
默认选 'region' 是因为区块会因悬浮抬升而上浮(约挤出深度的 0.7 倍)。挂在地图组上的标记停在原高度,
地块一抬就会从标记中间穿过去;挂到区块上则跟着一起浮动,始终贴在顶面。
顺带省掉一件麻烦事:区块高度是错落的(默认在基准深度的 0.82–1.38 倍之间),
'region' 下 height 相对顶面,你不必逐个去查各区块的挤出深度。
坐标落在所有区块之外(海上、数据范围外)时会自动退回 'map',此时 height 按绝对高度处理。
对象默认不参与射线拾取(挡在区块前面会让悬浮高亮在它上面断开),要能被点到就传 raycast: true。
资源归属:removeObject 只从场景摘除,不会 dispose 你的几何体与材质 —— 它们可能还在别处复用,
包越权释放会让别处渲染出黑块。map.dispose() 同理,只摘不放。自己建的自己释放。
挂 HTML 标记
复用包内部的 CSS2D 渲染器,自动跟随地图,不用自己算屏幕坐标:
map.addMarker(`<div class="my-bubble">北京 92</div>`, {
coordinate: [116.4074, 39.9042],
height: 4 // 同样是相对所在区块顶面的偏移
})定位规则与 addObject 完全一致,默认也贴在所在区块顶面、随抬升一起浮动。
标记默认不吃鼠标事件。需要点击就传 interactive: true,但注意它会挡住下面区块的悬浮高亮。
给每个区块标一个数值
getRegions() 给出各区块的名称、属性、落点与高度:
const values = { 广东省: 1284, 江苏省: 967 }
for (const region of map.getRegions()) {
const value = values[region.name]
if (value === undefined) continue
map.addMarker(`<b>${value}</b>`, {
coordinate: region.center, // 锚点经纬度
height: 1 // 相对顶面抬一点,避免和名称标注叠在一起
})
}region.center 是「离边界最远的点」而不是几何重心 —— 弯月形的省份(甘肃、内蒙古)
用重心会算到辖区之外。region.top 是同一个点的世界坐标,region.height 是它的挤出深度,
需要自己算三维位置时用。
动画
自定义对象的动画用 onFrame,别自己再起一条 requestAnimationFrame —— 那会多一次布局与绘制:
const off = map.onFrame((time) => {
pillar.scale.setScalar(1 + Math.sin(time / 400) * 0.1)
})补间同理,包内部用的是 gsap,你的项目里想用什么都行。
换数据后要重新定位
setData() 会按新数据重新算投影,场景坐标随之变化。包不知道你挂的那些对象各自对应哪个经纬度,
所以不会替你重算 —— 在 ready 事件里重画一次即可:
map.on('ready', () => {
clearMyOverlays()
drawMyOverlays()
})需要「换数据也不重算坐标」就锁死投影(projection: { center, scale }),此时场景坐标是稳定的。
自定义材质与效果
改材质
全部区块复用同一组共享材质(材质数不随区块数增长),所以改一处就是改全图:
const { top, side, outline, highlight, highlightTop } = map.getMaterials()
top.shininess = 80
top.specular.set('#8fe9ff')
outline.opacity = 0.7改数值即时生效;换贴图或改 defines 之后要设 material.needsUpdate = true。
材质清单:top(顶面)、side(侧壁)、outline(顶面描边)、baseOutline / baseOutlineFar
(底部两层投影光环)、borderFlow(最外圈电流线)、highlight / highlightTop(悬浮高亮的侧壁与顶面)。
side 的基色是纯白、颜色全部来自顶点色渐变,所以改它的 color 等于给整条渐变乘一个色乘子。
borderFlow 的 color 就是电流色,改它等价于 setColors({ borderFlow });可见范围由注入的
alpha 掐出来,所以电流关闭时整条线的 alpha 为 0,是真的不存在。
它是包里唯一不用叠加混合的发光元素。原因是电流跑在顶面描边同一条路径上,而描边本身已经
很亮(叠加混合的浅青,多数像素接近白),再叠加只能继续往白推 —— 配成红色也只是「白点变白点」。
改成正常混合、用 alpha 做覆盖率之后,电流经过处直接盖掉底下的描边,色相才由配置说话。
别把它改回 AdditiveBlending,那样 colors.borderFlow 就只剩亮度意义了。
调内部 uniform
顶面的纵深渐变、地形明暗、流光都是注入 shader 实现的,对应的 uniform 可以直接改:
const u = map.getUniforms()
u.uReliefAmount.value = 0 // 关掉地形明暗调制
u.uTiltFar.value = 1 // 关掉纵深渐变(uTiltNear 也设 1)
u.uTiltNear.value = 1
u.uSweepColor.value.set('#ff9ecb') // 换流光颜色
u.uSweepWidth.value = 8 // 流光的高斯半径,越大晕得越开叠加自己的贴图
terrain 配置项名字叫地形,机制其实是通用的:任何 Web 墨卡托(EPSG:3857)出图的影像
都能按 bbox 贴到顶面 —— 自己的卫星图、行政区配色图、光栅统计图都行。
terrain: {
relief: '/my-raster.jpg',
bounds: { minLon: 73.3, minLat: 3.6, maxLon: 135.3, maxLat: 53.8 }
}想完全自己控制(比如挂到别的通道、或用两张以上),用 applyGeoTextureTransform 处理 UV:
import { applyGeoTextureTransform } from 'three-geo-map3d'
import { SRGBColorSpace, TextureLoader } from 'three'
const texture = new TextureLoader().load('/my-raster.jpg')
texture.colorSpace = SRGBColorSpace // 当颜色贴图用就标 sRGB
applyGeoTextureTransform(texture, map.getProjection(), {
minLon: 73.3, minLat: 3.6, maxLon: 135.3, maxLat: 53.8
})
const { top } = map.getMaterials()
top.map = texture
top.needsUpdate = true
// 顶面默认会把 map 通道当灰度晕渲图做对比度拉伸,挂普通颜色贴图时要关掉
map.getUniforms().uReliefAmount.value = 0它能线性映射的原因:顶面的 UV 恰好就是投影后的平面坐标,而包内用的也是墨卡托,
两者同一种投影,所以只要一次 repeat / offset 就能对齐,不会形变。
bounds 必须与出图时的 bbox 严格一致,记错哪一侧影像就会整体偏移。
追加自己的 shader 代码
顶面与侧壁材质已经注入了包自己的代码(世界坐标 varying、纵深渐变、地形明暗、流光),
borderFlow 材质注入的是边缘电流。
直接赋值 material.onBeforeCompile 会把这些整体覆盖掉 —— 流光和纵深渐变会消失,
顶面还可能因为 fragment 引用了 vertex 没写入的 varying 而链接失败(整幅地图消失)。
用 extendMaterialShader 追加,它会先跑包内的注入再跑你的钩子,并且把 program 缓存键
在原基础上追加一段(不改缓存键的话,你的扩展会和包内的编译结果撞在同一缓存项上):
import { extendMaterialShader } from 'three-geo-map3d'
const { top } = map.getMaterials()
extendMaterialShader(
top,
(shader) => {
shader.uniforms.uTime = { value: 0 }
// 先判断分块存在再替换:three 升级后分块名可能变,判断一下就能退回原状而不是编译失败
if (shader.fragmentShader.includes('#include <common>')) {
shader.fragmentShader = shader.fragmentShader.replace(
'#include <common>',
'#include <common>\nuniform float uTime;'
)
}
if (shader.fragmentShader.includes('#include <opaque_fragment>')) {
shader.fragmentShader = shader.fragmentShader.replace(
'#include <opaque_fragment>',
`outgoingLight += vec3(0.0, 0.05, 0.1) * (0.5 + 0.5 * sin(uTime));
#include <opaque_fragment>`
)
}
},
'my-pulse' // 缓存键:同一种扩展用同一个键,不同扩展用不同键
)包内已经声明好、可以直接用的 varying 是 vMapWorldPosition(世界坐标)。
top 与 highlightTop 是两个独立材质,要让悬浮时效果一致,两个都得扩展。
后处理(bloom 等)
setRenderCallback 把绘制这一步交给你,其余环节(帧回调、尺寸同步、资源释放)照旧,
不用自己重建一套渲染循环:
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
const composer = new EffectComposer(map.getRenderer())
composer.addPass(new RenderPass(map.getScene(), map.getCamera()))
composer.addPass(new UnrealBloomPass(undefined, 0.8, 0.6, 0.85))
map.setRenderCallback(() => composer.render())
map.on('resize', ({ width, height }) => composer.setSize(width, height))传 null 恢复默认渲染。注意 CSS2D 的标注层不走 WebGL,不受后处理影响 —— 这通常正是想要的
(文字被 bloom 糊掉并不好看)。
完全换掉某个材质
上面这些都不够时,遍历区块自己换:
const myMaterial = new THREE.ShaderMaterial({ /* … */ })
for (const mesh of map.getMeshes()) {
mesh.material[0] = myMaterial // 槽 0 是顶面、槽 1 是侧壁
}代价要清楚:换掉的材质不再有包注入的流光与纵深渐变;入场动画的淡入是按共享材质列表写
opacity 的,你的材质不在那个列表里,所以入场时不会跟着淡入;悬浮高亮会在移出时把
mesh.material 恢复成替换前的引用。能接受这些再换。
更底层的逃生舱
上面这些还不够时,直接拿原生对象:
map.getScene() // THREE.Scene
map.getCamera() // THREE.PerspectiveCamera
map.getRenderer() // THREE.WebGLRenderer
map.getControls() // OrbitControls
map.getGroup() // 区块所在的 Group(已含 -90° 旋转)
map.getMeshes() // 各区块 Mesh,userData 上有 regionId / regionName / properties
map.project([116.4, 39.9], 4) // 经纬度 → 世界坐标
map.projectLocal([116.4, 39.9], 4) // 经纬度 → 区块组局部坐标(z 为高度)
map.unproject({ x: 12, z: -8 }) // 世界坐标 → 经纬度事件
const off = map.on('click', ({ id, name, properties }) => {
console.log(name)
})
off() // 注销| 事件 | 载荷 | 说明 |
| --- | --- | --- |
| ready | { regionCount } | 数据加载并建模完成 |
| click | { id, name, properties, mesh, event } | 点击某个区块 |
| hover | 同上(移出时为 null) | 悬浮变化 |
| error | Error | 加载或建模失败 |
| statuschange | 状态字符串 | 状态变化 |
| resize | { width, height } | 容器尺寸同步完成(接了后处理时用来同步 composer) |
也可以在配置里直接给:on: { ready() {}, click() {} }。这样注册的回调一定赶得上首次加载。
常见用法
复刻固定的视觉比例
默认每次换数据都会按新范围重新取景(单省数据会被放大到与全国图一样的画面占比)。 希望「换数据时地图大小不变」就锁死投影:
createGeoMap3D({
container: '#map',
data: '/geo/china.json',
projection: { center: [104, 37.5], scale: 60 }
})按业务指标定高低
const values = { 广东省: 0.9, 江苏省: 0.7 }
createGeoMap3D({
container: '#map',
data: geoJson,
extrude: {
// 返回高度倍数;返回 undefined 时回落到默认的稳定哈希
resolveLevel: (properties) => {
const value = values[properties.name]
return value === undefined ? undefined : 0.8 + value * 1.2
}
}
})自定义提示内容
interaction: {
tooltipFormat: ({ regionName, properties }) =>
`<b>${regionName}</b><br/>告警 ${properties.alarm ?? 0} 起`
}地形底图(可选)
顶面可以叠两张真实地理影像:山体阴影刻出山脉走向,真彩合成补上地物明暗(荒漠亮、林区暗、
雪线最亮)。只给 relief 也能用,少一层地物明暗而已。
terrain: {
relief: '/textures/relief.jpg',
satellite: '/textures/satellite.jpg',
bounds: { minLon: 73.3024, minLat: 3.6236, maxLon: 135.2957, maxLat: 53.7633 }
}硬要求两条:
- 影像必须是 Web 墨卡托(EPSG:3857) 出图,
bounds与出图时的 bbox 严格一致。 记错哪一侧,山脉就会整体偏移。 - 跨域影像要允许 CORS,否则包读不到像素、无法实测明暗档位,会退回一组通用经验值 (能看,但对比度不一定合适)。
加载失败不会让地图进失败态,只是退回纯色顶面。另外:terrain 的 UV 映射是在首次投影上算的,
所以同时使用 terrain 与 setData() 时建议锁死 projection,否则换数据后贴图会错位。
在 Vue 3 里
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { createGeoMap3D } from 'three-geo-map3d'
const el = ref(null)
let map = null
onMounted(() => {
map = createGeoMap3D({ container: el.value, data: '/geo/china.json' })
})
onBeforeUnmount(() => map?.dispose())
</script>
<template>
<div ref="el" class="map"></div>
</template>在 React 里
useEffect(() => {
const map = createGeoMap3D({ container: ref.current, data: geoJson })
return () => map.dispose()
}, [])样式
标注与提示是包自己创建的 DOM,样式默认注入,颜色走容器上的 CSS 变量,
所以同一页面的多个实例可以各用一套配色。想完全自己接管就把 injectStyle 设为 false,
然后针对这几个类名写样式:
.gm3d-label-layer标注层容器.gm3d-label/.gm3d-label__text单个标注与其内层文本(缩放写在内层).gm3d-tooltip悬浮提示
本地开发
npm run dev # 起示例页(读上层项目 public/geo/china.json)
npm run build # 产出 dist/示例页可以切数据集、切主题、拖视角,用来验证「换数据自动重新取景」这类行为。
发布
npm version patch
npm publish --access public包名 three-geo-map3d 若已被占用,改 package.json 的 name 即可(例如换成
@你的组织/geo-map3d,此时 publish 需要带 --access public)。
许可
MIT
