npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@orochitian/map-3d

v0.1.0

Published

基于 Three.js 的 3D 立体行政区划地图,传入 GeoJSON 与少量配置即可生成一张可交互的科技风立体地图

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 gsap

three 与 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 }
}

硬要求两条:

  1. 影像必须是 Web 墨卡托(EPSG:3857) 出图,bounds 与出图时的 bbox 严格一致。 记错哪一侧,山脉就会整体偏移。
  2. 跨域影像要允许 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