You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

D3 Brush & Zoom多次缩放异常:变换基于初始参数而非更新值

D3.js 交互式地图刷选缩放二次失效问题修复

问题现象

基于D3开发的交互式地图支持拖拽矩形框刷选缩放,首次刷选、重置后刷选均正常,但处于缩放状态时二次刷选,brush会基于原始SVG范围检测选区,无法匹配当前缩放后的视图,导致无法正确缩放至刷选区域。

问题根源

brush直接绑定在SVG根元素上,而缩放仅通过修改地图和点元素的transform属性实现,brush的坐标系统始终未同步当前缩放变换,导致二次刷选时获取的选区坐标是原始画布位置,而非当前视图对应的实际区域。

修复方案

  1. 统一缩放容器:将陆地路径和点元素放入同一个<g>容器,所有缩放变换直接作用于该容器,避免重复设置transform。
  2. 同步brush与缩放变换:在brush事件中,通过d3.zoomTransform获取当前缩放状态,将brush选区坐标转换为匹配当前视图的坐标。
  3. 修正点坐标的刷选判断:将点的投影坐标逆变换为原始坐标,再与转换后的brush选区范围对比,确保刷选高亮逻辑正确。

修改后的完整代码

let width = 1200;
let height = 800;

const svg = d3
  .select("#chart1")
  .append("svg")
  .attr("width", width)
  .attr("height", height)

const projection = d3.geoMercator()
  .scale(width / 2.5 / Math.PI)
  .rotate([0, 0])
  .center([0, 0])
  .translate([width / 2, height / 2]);

const path = d3.geoPath().projection(projection);

const colorScale = 
  d3.scaleOrdinal().domain(["class1","class2","class3","class4","class5"])
  .range(["#a6cee3", "#1f78b4", "#b2df8a", "#33a02c", "#fb9a99"]);


let brush = d3.brush()
  .on('brush', brushMove)
  .on("end", brushEnd);

let brushExtent;
let brushedSufficient = true;

const zoom = d3.zoom()
  .scaleExtent([1, Infinity])
  .translateExtent([[0, 0], [width, height]])
  .extent([[0, 0], [width, height]])
  .on("zoom", zoomed);

// 创建统一的缩放容器
const mapGroup = svg.append("g").attr("class", "map-group");

function brushMove(e) {
  brushExtent = e.selection;
  if (!brushExtent) return;

  // 获取当前缩放变换,转换brush选区坐标
  const transform = d3.zoomTransform(mapGroup.node());
  const invertedExtent = brushExtent.map(transform.invert);
  
  svg.selectAll("circle.point")
    .attr("fill", d => isInBrushExtent(d, invertedExtent) ? 'white' : colorScale(d.cat))
    .raise();

  brushedSufficient = isSufficient();
}

function isInBrushExtent(d, extent) {
  const [x, y] = projection([d.lon, d.lat]);
  return x >= extent[0][0] && x <= extent[1][0] && y >= extent[0][1] && y <= extent[1][1];
}

function isSufficient() {
  return brushExtent[1][0] - brushExtent[0][0] > 10 && brushExtent[1][1] - brushExtent[0][1] > 10;
}

function brushEnd() {
  if (!brushExtent || !brushedSufficient) return;

  const transform = d3.zoomTransform(mapGroup.node());
  const invertedExtent = brushExtent.map(transform.invert);
  
  const x0 = invertedExtent[0][0];
  const x1 = invertedExtent[1][0];
  const y0 = invertedExtent[0][1];
  const y1 = invertedExtent[1][1];

  const x = -(x0 + x1) / 2;
  const y = -(y0 + y1) / 2;
  const k = Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height));

  svg.transition().duration(750).call(
    zoom.transform,
    d3.zoomIdentity
      .translate(width / 2, height / 2)
      .scale(k)
      .translate(x, y),
  )
}

function zoomed(e) {
  const transform = e.transform;
  // 仅对统一容器应用变换
  mapGroup.attr('transform', transform)
    .selectAll('path.land')
    .attr("stroke-width", 1 / transform.k);
  mapGroup.selectAll("circle.point")
    .attr('r', 5 / transform.k);
}

function resetZoom() {
  svg.transition()
    .duration(750)
    .call(zoom.transform, d3.zoomIdentity)
}

d3.select("#resetZoom").on("click", resetZoom);

// 将陆地路径放入缩放容器
mapGroup.selectAll("path.land")
  .data(land.features)
  .join("path")
  .attr("class", "land")
  .attr("fill", "white")
  .attr("stroke", "#808080")
  .attr("d", path);

// brush绑定在SVG根元素,确保刷选区域覆盖整个画布
svg.append("g")
  .attr("class", "brush")
  .call(brush);

// 将点放入缩放容器
mapGroup.selectAll("circle.point")
  .data(data)
  .join("circle")
  .attr("class", "point")
  .attr("id", d => "id" + d.id)
  .attr("cx", d => projection([d.lon, d.lat])[0])
  .attr("cy", d => projection([d.lon, d.lat])[1])
  .attr("stroke-width", 1)
  .attr("r", 5)
  .attr("fill", d => colorScale(d.cat))
  .attr("cursor", "pointer")
  .on("mouseover", (e, d) => console.log(d.name))

内容的提问来源于stack exchange,提问作者jhjanicki

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 16:37:19