简介:这份资源围绕 d3.js 交互式等值线图展开,面向具备一定前端基础、希望在地图可视化方向进阶的开发者与数据分析人员。它解决的是如何把地理边界与统计数据结合,快速生成可交互、可配色、可读图例的等值线地图,适用于区域数据展示、统计报告与教学演示等场景。压缩包共132个文件,约1.13MB,以less与css样式文件、js脚本、liquid模板为主,另含csv数据、html页面、json配置及字体与R相关文件,覆盖样式定制、数据读取与页面渲染等环节。资源内置albersUSA投影、州与县边界、读取CSV(含Google Doc来源)、工具提示、自动与自定义图例、自动配色与自定义颜色,并支持分类或线性数据。已有459人学习下载,读者可据此掌握从数据到交互地图的完整实现思路,并借助现成样式与脚本快速改造出符合自身需求的可视化作品。
1. 等值线图不是“画几条线”,而是把连续场翻译成可读边界
等值线图(contour / isopleth)在气象、环境监测、热力分布、销量密度这些场景里出现频率极高。它要解决的核心问题只有一个:把一张连续变化的二维标量场,切成若干条“数值相等的边界线”,让读者一眼看出哪里高、哪里低、哪里陡。很多人第一次用 d3js 做等值线图,会下意识去找“画线”的 API,结果翻车——d3 本身不直接生成等值线,它靠的是d3-contour这个模块先把栅格数据算成多边形路径,再交给d3-geo或普通 SVG path 渲染。
这篇笔记讲的是 d3QuickMap 这个思路:用 d3js 把等值线图做成可交互的。所谓交互,不是加个 tooltip 就完事,而是鼠标悬停能读出该点数值、点击某条等值线能高亮整条边界、缩放时线宽和标注不糊。适合两类人:一类是手里有网格数据(CSV、JSON、GeoTIFF 转出来的数组)想快速出图的前端;另一类是做数据看板,需要把等值线叠在地图底图上,又不想引入重型 GIS 库的工程师。下面按“数据长什么样 → 怎么算等值线 → 怎么画 → 怎么交互 → 坑在哪”的顺序推一遍,每步都能直接抄。
2. 等值线图的数据底座:从网格矩阵到 d3-contour 的输入约定
2.1 为什么等值线图必须先有规则网格
等值线算法本质上是在离散采样点上做插值再追踪等值边界。d3-contour 的contours()接收的是一个一维扁平数组,外加width和height两个整数,表示这个数组按行优先铺成width × height的矩阵。也就是说,你的原始数据必须能整理成“第 i 行第 j 列有一个数值”的形式。常见来源有三种:气象站点插值后的格点、图像灰度值、CSV 里带 x/y/value 三列再 pivot 成矩阵。
这里有个容易忽略的点:d3-contour 不关心你的坐标是经纬度还是像素,它只认矩阵索引。所以如果你要叠在地图上,得自己把矩阵索引映射到投影坐标。我一般会先把数据整理成下面这种结构,再喂给 d3:
// 假设原始 CSV 是 x,y,value 三列,已经按 x 升序、y 升序排好 // 先算出唯一的 x 列表和 y 列表,确定网格宽高 const xs = [...new Set(raw.map(d => d.x))].sort((a, b) => a - b); const ys = [...new Set(raw.map(d => d.y))].sort((a, b) => a - b); const width = xs.length; const height = ys.length; // 建立索引映射,避免 O(n^2) 查找 const xIndex = new Map(xs.map((v, i) => [v, i])); const yIndex = new Map(ys.map((v, i) => [v, i])); // 扁平数组,行优先:先第一行所有列,再第二行…… const values = new Float64Array(width * height); raw.forEach(d => { const col = xIndex.get(d.x); const row = yIndex.get(d.y); values[row * width + col] = d.value; });这段代码的关键在最后一行:row * width + col是行优先展开的标准写法,d3-contour 内部就是按这个顺序读的。Float64Array不是必须,但大数据量下比普通数组省内存。参数说明:width和height必须和values.length满足values.length === width * height,否则contours()会算出错位的边界,这种错误不会报异常,只会让图“看起来怪怪的”,属于典型黑匣子问题。
2.2 阈值怎么定:等值线密度与可读性的平衡
contours()的第二个参数是阈值数组,比如[0, 10, 20, 30, 40],表示你要在哪些数值上切等值线。阈值不是越多越好。我见过有人直接传 50 个阈值,结果图上一片糊,线比数据还密。经验做法是:先看数据的分布,用分位数而不是等间距。比如数据范围 0 到 100,但 90% 的值集中在 20 到 40,那等间距切出来的线全挤在中间。
// 用 d3.quantile 算分位数阈值,比等间距更贴合数据分布 const sorted = Array.from(values).sort((a, b) => a - b); const thresholds = d3.range(1, 10).map(i => d3.quantile(sorted, i / 10)); // 结果类似 [12.3, 18.7, 22.1, ...],共 9 条等值线d3.quantile(sorted, p)返回第 p 分位数,p 取 0 到 1。这里取 1/10 到 9/10,得到 9 个阈值,对应 9 条等值线。如果数据有明确的业务分级(比如空气质量指数 50、100、150),那就直接用业务阈值,别用分位数,因为读者关心的是“有没有超标”,不是“分布均不均匀”。
提示:阈值数组必须严格递增,d3-contour 不会帮你排序。传了乱序数组,输出的多边形顺序会乱,颜色映射也会错位。
3. 用 d3-contour 算出等值线路径:从矩阵到 SVG path 的完整链路
3.1 最小可运行示例:10 行代码拿到等值线多边形
先把链路跑通,再谈交互。下面这段代码不依赖任何地图投影,纯 SVG 画布,目的是让你确认数据到路径的转换是对的。
import * as d3 from 'd3'; // 1. 准备一个 20x20 的模拟场:中心高、四周低 const width = 20, height = 20; const values = new Float64Array(width * height); for (let r = 0; r < height; r++) { for (let c = 0; c < width; c++) { const dx = c - width / 2, dy = r - height / 2; values[r * width + c] = 100 * Math.exp(-(dx * dx + dy * dy) / 50); } } // 2. 生成等值线几何 const contourGenerator = d3.contours() .size([width, height]) .thresholds([10, 30, 50, 70, 90]); const contourData = contourGenerator(values); // contourData 是数组,每个元素有 .value(阈值)和 .coordinates(多边形环数组) // 3. 映射到 SVG 路径 const path = d3.geoPath(); // 默认 identity 投影,直接按像素坐标画 const svg = d3.select('body').append('svg') .attr('width', 400).attr('height', 400) .attr('viewBox', `0 0 ${width} ${height}`); svg.selectAll('path') .data(contourData) .join('path') .attr('d', path) .attr('fill', d => d3.interpolateViridis(d.value / 100)) .attr('stroke', '#333') .attr('stroke-width', 0.3);逻辑说明:d3.contours().size([width, height])告诉生成器矩阵尺寸,.thresholds()设定切分位置。contourData里每个元素是一个MultiPolygon几何对象,coordinates是三维数组(多边形 → 环 → 点)。d3.geoPath()默认用 identity 投影,也就是把数据坐标直接当像素坐标,所以 viewBox 设成0 0 20 20时,图会被放大到 400px,线宽 0.3 在视觉上刚好。
参数说明:stroke-width在 viewBox 缩放下会被同步放大,如果你希望线宽恒定,得用vector-effect="non-scaling-stroke",这个后面交互章节会讲。interpolateViridis是连续色带,传入 0 到 1 的值,这里用d.value / 100归一化。
3.2 把等值线叠到真实地图上:投影与坐标对齐
纯 SVG 只能看形状,真正落地多半要叠地图。假设你有一组经纬度网格数据,矩阵的列对应经度、行对应纬度,那么需要两步:先用d3.geoContour的坐标变换能力,或者手动把矩阵索引转成经纬度,再套投影。
// 假设 xs 是经度数组,ys 是纬度数组(注意纬度通常从北到南递减) const projection = d3.geoMercator() .fitSize([800, 600], { type: 'Sphere' }); // 或 fitExtent 到你的边界框 // d3-contour 输出的坐标是矩阵索引 [col, row] // 需要转成经纬度再投影 const path = d3.geoPath(projection); // 关键:用 transform 把索引坐标映射到经纬度 const contourGenerator = d3.contours() .size([width, height]) .thresholds(thresholds); const contourData = contourGenerator(values); // 手动转换每个点 const toLonLat = ([col, row]) => [ xs[Math.round(col)], ys[Math.round(row)] ]; // 如果直接用 geoPath,需要构造 GeoJSON 并逐点转换 // 更稳妥的做法是用 d3.geoTransform const transform = d3.geoTransform({ point(x, y) { const [lon, lat] = toLonLat([x, y]); const [px, py] = projection([lon, lat]); this.stream.point(px, py); } }); const pathWithTransform = d3.geoPath(transform);这里有个血泪经验:d3.geoTransform的point方法里,this.stream.point是固定写法,不能改成别的。另外Math.round(col)是因为等值线追踪出来的点可能是小数索引,而xs数组只能整数索引。如果网格很密,四舍五入误差可以忽略;如果网格稀疏,建议用线性插值而不是取整,否则等值线会有锯齿。
注意:经纬度网格如果纬度是递减的(北到南),
ys数组顺序要和矩阵行顺序一致,否则图会上下颠倒。这个错误很隐蔽,因为形状看起来“差不多”,只是位置反了。
4. 交互不是加个 tooltip:悬停读数、点击高亮与缩放重绘
4.1 悬停读出任意点数值:反查矩阵而不是查多边形
等值线图最自然的交互是鼠标移到图上,显示该位置的实际数值。很多人第一反应是给每个多边形绑 mouseover,但等值线多边形是“带状”的,一个点可能落在两条线之间,读出来的值只能是阈值,不是真实值。正确做法是:监听 SVG 的 mousemove,把鼠标像素坐标反投影回矩阵索引,再去values数组里取值。
const svg = d3.select('svg'); const tooltip = d3.select('body').append('div') .style('position', 'absolute') .style('pointer-events', 'none') .style('background', 'rgba(0,0,0,0.75)') .style('color', '#fff') .style('padding', '4px 8px') .style('border-radius', '3px') .style('display', 'none'); svg.on('mousemove', function(event) { const [mx, my] = d3.pointer(event, this); // 如果用了投影,这里要先反投影;纯 SVG 则直接按 viewBox 缩放比例换算 const col = Math.round(mx / 400 * width); const row = Math.round(my / 400 * height); if (col < 0 || col >= width || row < 0 || row >= height) return; const val = values[row * width + col]; tooltip .style('display', 'block') .style('left', (event.pageX + 12) + 'px') .style('top', (event.pageY - 12) + 'px') .text(`值: ${val.toFixed(2)}`); }); svg.on('mouseleave', () => tooltip.style('display', 'none'));逻辑说明:d3.pointer(event, this)返回鼠标相对于 SVG 元素的坐标。因为 viewBox 是0 0 20 20而实际渲染 400px,所以mx / 400 * width把像素坐标转回矩阵列索引。Math.round取最近格点。参数说明:toFixed(2)控制小数位,如果数据本身是整数,改成toFixed(0)。这个方案在缩放后需要重新计算比例,后面会讲。
4.2 点击等值线高亮整条边界
高亮某条等值线,本质是给对应的 path 加一个 class 或改 stroke。因为contourData里每个元素对应一个阈值,所以直接绑 click 即可。
svg.selectAll('path.contour') .data(contourData) .join('path') .attr('class', 'contour') .attr('d', path) .attr('fill', d => d3.interpolateViridis(d.value / 100)) .attr('stroke', '#333') .attr('stroke-width', 0.3) .on('click', function(event, d) { // 清除其他高亮 svg.selectAll('path.contour') .attr('stroke', '#333') .attr('stroke-width', 0.3); // 高亮当前 d3.select(this) .attr('stroke', '#e74c3c') .attr('stroke-width', 1.5) .raise(); // 提到最上层,避免被其他多边形盖住 });raise()是 d3 的选择集方法,把当前元素移到父节点最后,相当于 SVG 里的“置顶”。如果不加,高亮线可能被相邻多边形遮住一半。参数说明:stroke-width从 0.3 改到 1.5 是视觉对比,具体值看你的 viewBox 缩放比例。
4.3 缩放时线宽不糊、标注不飘
d3 的 zoom 行为默认会改 transform,导致 stroke-width 被缩放。解决办法有两种:一是用vector-effect="non-scaling-stroke",二是监听 zoom 事件手动重算。我一般用第一种,简单。
const zoom = d3.zoom() .scaleExtent([1, 8]) .on('zoom', (event) => { g.attr('transform', event.transform); }); svg.call(zoom); // 在 path 上加 vector-effect svg.selectAll('path.contour') .attr('vector-effect', 'non-scaling-stroke');scaleExtent([1, 8])限制缩放范围,防止缩太小或太大。event.transform是当前的平移缩放矩阵,直接应用到<g>容器上。non-scaling-stroke让线宽始终按屏幕像素算,不随缩放变粗。这个属性在 Chrome、Firefox、Safari 都支持,但 IE 不支持——如果还要兼容 IE,就得在 zoom 回调里手动设stroke-width = baseWidth / event.transform.k。
提示:缩放后 tooltip 的反查逻辑要跟着变。如果用了
<g transform>,d3.pointer返回的坐标是相对于<g>的,需要先逆变换。简单做法是把 mousemove 绑在<g>上而不是 svg 上。
5. 避坑与排查:等值线图最容易翻车的 5 个地方
5.1 现象:等值线画出来是空白,控制台无报错
原因:values数组长度和width * height不匹配,或者thresholds全部超出数据范围。d3-contour 对空结果不报错,直接返回空数组。
解决:在调用contours()之前打印values.length、width * height、d3.extent(values),确认三者一致且阈值落在数据范围内。
5.2 现象:等值线边缘有锯齿或断裂
原因:网格分辨率太低,或者等值线追踪时用了取整而不是插值。d3-contour 内部有 marching squares 的线性插值,但如果你的数据本身是整数索引映射,边界点会落在格点之间,取整就会断。
解决:提高网格密度,或者在geoTransform里用双线性插值代替Math.round。简单场景下,把size([width, height])改成size([width * 2, height * 2])并上采样数据,也能缓解。
5.3 现象:叠地图后等值线整体偏移
原因:投影的fitSize或fitExtent用的边界框和你的数据范围不一致,或者经纬度顺序搞反(经度当纬度)。
解决:先用projection([lon, lat])打印几个已知点的像素坐标,和地图底图上的对应位置比对。确认xs是经度、ys是纬度,且ys顺序和矩阵行顺序一致。
5.4 现象:tooltip 数值和颜色对不上
原因:颜色映射用的是阈值d.value,而 tooltip 用的是原始values数组。两者本身就不一样——等值线之间的区域颜色代表的是“区间”,不是精确值。
解决:如果希望 tooltip 显示区间,就用d3.scaleThreshold反查;如果希望显示精确值,就保留反查矩阵的方案,但要在 UI 上说明“颜色为区间,数值为精确采样”。
5.5 现象:缩放后 tooltip 位置漂移
原因:d3.pointer返回的坐标是相对于绑定元素的,如果绑在 svg 上而内容在<g transform>里,坐标没算上 transform。
解决:把 mousemove 绑在<g>上,或者手动用event.transform.invert([mx, my])逆变换。后者更通用,但要注意invert返回的是数据坐标,还要再转矩阵索引。
6. 进阶技巧:用 canvas 渲染万级网格,用色带标注替代图例
当网格超过 500×500,SVG 的 path 数量会爆炸,浏览器直接卡死。这时候换 canvas 渲染,d3-contour 照样能用,只是把geoPath换成d3.geoPath(projection, context),其中context是 canvas 的 2D 上下文。
const canvas = d3.select('body').append('canvas') .attr('width', 800).attr('height', 600) .node(); const ctx = canvas.getContext('2d'); const path = d3.geoPath(projection, ctx); // 清空画布 ctx.clearRect(0, 0, 800, 600); // 逐条绘制等值线 contourData.forEach(d => { ctx.beginPath(); path(d); ctx.fillStyle = d3.interpolateViridis(d.value / 100); ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 0.5; ctx.stroke(); });canvas 方案下,交互要用ctx.isPointInPath或者自己维护一个颜色缓冲区反查。我一般用后者:把每个像素的颜色值存到一个离屏 canvas,鼠标移动时读像素,反查阈值区间。这样比逐个多边形判断快得多。
另一个技巧是图例。等值线图的图例不是离散色块,而是连续色带加刻度。用d3.scaleLinear生成渐变,再用d3.axisBottom画刻度,比手写一堆<rect>省事。
const legendWidth = 300, legendHeight = 20; const legendSvg = d3.select('body').append('svg') .attr('width', legendWidth + 60) .attr('height', legendHeight + 40); const defs = legendSvg.append('defs'); const gradient = defs.append('linearGradient') .attr('id', 'legend-gradient'); gradient.selectAll('stop') .data(d3.range(0, 1.01, 0.1)) .join('stop') .attr('offset', d => `${d * 100}%`) .attr('stop-color', d => d3.interpolateViridis(d)); legendSvg.append('rect') .attr('x', 30).attr('y', 10) .attr('width', legendWidth).attr('height', legendHeight) .attr('fill', 'url(#legend-gradient)'); const legendScale = d3.scaleLinear() .domain(d3.extent(values)) .range([30, 30 + legendWidth]); legendSvg.append('g') .attr('transform', `translate(0, ${10 + legendHeight})`) .call(d3.axisBottom(legendScale).ticks(5));这段代码里,linearGradient的stop用d3.range(0, 1.01, 0.1)生成 11 个色标,和interpolateViridis的输入范围对齐。legendScale把数据范围映射到像素范围,axisBottom自动生成刻度。参数说明:ticks(5)是建议刻度数,d3 会根据范围自动调整,不保证正好 5 个。
最后说个习惯:我每次做完等值线图,都会把values数组的d3.extent、阈值数组、以及第一个多边形的coordinates长度打印出来。这三个数能覆盖 80% 的“图不对”问题——范围不对说明数据没对齐,阈值不对说明切分位置错了,坐标长度不对说明网格尺寸传错了。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取