简单易用、可交互的热力图

04/04/2024

简单易用、可交互的热力图

在实现一个可交互的热力图时,有去参考市面上可用的前端实现的热力图。

目前大多数基本上只是对热力数据的展示,或者是根据数据在真实地图上对不同省/市进行标色。

大多并没有针对热力图的交互。

在这里,我实现了一个可以进行一些简单交互的热力图,比如说,地图缩放、数据过滤、热力半径设置等。

我实现的热力图中对于热力数据的渲染是基于partric的heatmapjs库,感谢他。

这儿是一个简单的demo。

API

先简单介绍一下API。

type heatmapProps = {
  data: [xCoordinate: number, yCoordinate: number, value: number][];
  mapFile?: {
    image: string; // img url
    imgSize: TPoint
    coordinate: {
      x: TPoint; // left bottom point's coordinate
      y: TPoint; // right top point's coordinate
    }
  }
  heatmapConfig?: TheatmapConfig
  onConfigChange?: (v: TheatmapConfig) => void;
  onPostionChange?: (v: TOnPositionChangeProp) => void;
  localeMap?: THeatmapLocale
  documentResizeEventKey?: string
}
 
type heatmapRef = {
  handleSyncAction?: (v: THandleSyncActionProp) => void
}
 
type TPoint = {
  x: number; // x coordinate or width
  y: number; // y coordinate or height
}
 
type TheatmapConfig = {
  opacity?: number; // default 100
  radius?: number; // heatmap data point radius, default to 20
  theme?: number; // default to 0
}
 
type TOnPositionChangeProp = {
  scale: number
  bgP: {
    x: TPoint;
    y: TPoint;
  }
}
 
type THeatmapLocale = {
  opacity?: string;
  radius?: string;
  colorTheme?: string;
  reset?: string;
  zoomOut?: string;
  zoomIn?: string;
  setting?: string;
}
 
type THandleSyncActionProp = {
  scale: number
  bgP: {
    x: TPoint;
    y: TPoint;
  }
}
 

data

完全同partric的heatmapjs库,[横坐标,纵坐标,值]的一个数组。

mapFile

如果不传地图文件url的话,将会默认是一张透明图片。

参数中的地图文件的像素大小imgSize用于地图的适配与缩放,之后可以优化成在代码中获取地图的实际像素尺寸。

参数中的坐标coordinate里面的x和y分别是左下角和右上角的坐标值,用于放置热力数据。

heatmapConfig

热力图的一些配置,比如不透明度、热力半径、颜色主题等。

onConfigChange

该方法将会在热力图配置被修改时被调用。

onPostionChange

该方法将会在用户缩放地图、拖动地图时调用,参数是缩放大小及中心位置。

localeMap

一般用于i18n,即国际化文案。

documentResizeEventKey

挂载在document上的eventKey,外层写法类似如下

  document.dispatchEvent(documentResizeEventKey)

当如此执行时,将会触发热力图因为页面缩放而进行的重新绘制。

Ref - handleSyncAction

一般用于在多个热力图之间同步缩放拖拽动作。

地图缩放与适配

适配

热力图首先要保证在初始情况下地图能够完整的展示出来,以及按照原有的比例进行展示。

按照这个思路,其实可供选择的方案是不多的,

一个是 background-size: contain;

另一个则是,去计算图片尺寸宽高与容器宽高的比例,根据比例设置成 background-size: auto 100%;,比值大的为 100%

考虑到要兼容地图放大情况下的backgroundSize,我在此处选择的是第二个方案。

缩放

依着适配的思路,这边就可以直接去选用background-size: auto 200%;

地图拖拽

地图拖拽是基于监听背景为地图的容器的mousemove事件,根据事件的clientX, clientY及记录的上一次位置信息,修改容器的backgroundPosition

mousedown被触发时,标记拖拽开始,并且记录鼠标初始位置信息及当前容器的backgroundPosition

mousemove被触发时,新的位置计算方法类似如下

newPositionX = // 新的position值
  oldPositionX + // 旧的position值
  ((oldClientX - event.clientX) /* 鼠标移动路径 */  /4 /* 控制移动速度 */ / containerSize.width /* 相对容器宽度的百分比 */ ) * 100;

mouseup, mouseout被触发时,标记拖拽结束。

数据填入

数据填入是整个实现中最麻烦的地方。因为地图的缩放、拖拽与热力库是不同的实现,当前视窗下的数据范围,就需要自己去根据缩放、背景位置、地图坐标、地图大小等去计算出来。

基本变量定义

由于背景图片的宽高比例和容器的宽高比例基本不可能一致,背景上也就会在上传的地图图片两侧有透明层(默认背景色)。

但是对于heatmapjs库来说,所有的背景区域,包括透明层,都算作热力图区域。

此处就需要进行一次数据映射,即地图大小(图片宽高)与热力图坐标(容器宽高)的数据映射。

这里首先定义了一个变量 scale0Size,意为在当前容器宽高、缩放为0的情况下,地图图片在容器内的宽高,以及与容器宽高比例较大的那一边(宽或高)。该变量取值如下

function getScale0Value(containerSize: TPoint, imageSize: TPoint) {
  const ratioW = imageSize.x / containerSize.x;
  const ratioH = imageSize.y / containerSize.y;
  let scale = Math.max(ratioW, ratioH);
  let autoW = ratioW < ratioH;
 
  return {
    x: !autoW ? containerSize.x : imageSize.x / scale,
    y: !autoW ? imageSize.y / scale : containerSize.y,
    xFlag: autoW, // 比例较大的是否为宽
  };
}

以及与之类似的,获得未占满轴的那个边的实际长度。如下代码中已经带有后续逻辑中的缩放倍数。

function getUnfilledAxis(
  scaleValue: number,
  containerSize: TPoint,
  imageScale0Value: TPoint,
  xFlag: boolean,
): {
  unfilledLength: number;
} | null {
  if (
    imageScale0Value.x * 2 ** (scaleValue - 1) > containerSize.x &&
    imageScale0Value.y * 2 ** (scaleValue - 1) > containerSize.y
  ) {
    return null;
  }
  const unfilledLength = xFlag
    ? imageScale0Value.x * 2 ** (scaleValue - 1)
    : imageScale0Value.y * 2 ** (scaleValue - 1);
 
  return {
    unfilledLength,
  };
}

然后就可以去热力图实际的地图区域数据范围

实际的地图区域指的是,假如容器宽高为 400,400,地图图片在容器内的宽高为 200,400,则实际的地图区域为 [100, 0],[300, 400]。
数据范围则是由于用户给定的数据集合的横纵坐标可能超出设定的地图/当前地图区域的最大最小横纵坐标,该范围可以在此时计算出来。

这部分逻辑实现在未缩放或有放大时有一些不同

无缩放时
function getRange() {
  const container = getContainerSize();
  const unfillLineResult = getUnfilledAxis(
    scale,
    container,
    scale0Size,
    scale0Size.xFlag,
  );
  if (!unfillLineResult) {
    return;
  }
  // unfilledLength 即为 没有占满轴的 那条边 的实际长度
  const { unfilledLength } = unfillLineResult;
 
  let p1: TPoint = { x: 0, y: 0 },
    p2: TPoint = { x: container.x, y: container.y };
 
  // offset 为 偏移量
  const offset = xFlag
    ? (backgroundPosition.x / 100) * (container.x - unfilledLength)
    : (backgroundPosition.y / 100) * (container.y - unfilledLength);
 
  if (xFlag) {
    p1.x = 0 + offset;
    p2.x = unfilledLength + offset;
  } else {
    p1.y = 0 + offset;
    p2.y = unfilledLength + offset;
  }
 
  setDisplayRange({ p1, p2 });
  setDataRange({
    p1: { x: coordinate.p1.x, y: coordinate.p1.y },
    p2: { x: coordinate.p2.x, y: coordinate.p2.y },
  });
}
有放大时
function getRange() {
  const container = getContainerSize();
  const unfillLineResult = getUnfilledAxis(
    scale,
    container,
    scale0Size,
    scale0Size.xFlag,
  );
 
  let displayP1: TPoint = { x: 0, y: 0 },
    displayP2: TPoint = { x: container.x, y: container.y };
 
  let dataP1: TPoint = {
      x: getDataPos({
        position: backgroundPosition.x,
        containerLen: container.x,
        scaledLen: scale0Size.x * 2 ** (scale - 1),
        coorStart: coordinate.p1.x,
        coorEnd: coordinate.p2.x,
        isLowerCoor: true,
      }),
      y: getDataPos({
        position: 100 - backgroundPosition.y,
        containerLen: container.y,
        scaledLen: scale0Size.y * 2 ** (scale - 1),
        coorStart: coordinate.p1.y,
        coorEnd: coordinate.p2.y,
        isLowerCoor: true,
      }),
    },
    dataP2: TPoint = {
      x: getDataPos({
        position: backgroundPosition.x,
        containerLen: container.x,
        scaledLen: scale0Size.x * 2 ** (scale - 1),
        coorStart: coordinate.p1.x,
        coorEnd: coordinate.p2.x,
        isLowerCoor: false,
      }),
      y: getDataPos({
        position: 100 - backgroundPosition.y,
        containerLen: container.y,
        scaledLen: scale0Size.y * 2 ** (scale - 1),
        coorStart: coordinate.p1.y,
        coorEnd: coordinate.p2.y,
        isLowerCoor: false,
      }),
    };
  if (unfillLineResult) {
    const { unfilledLength } = unfillLineResult;
    const offset = xFlag
      ? 0.5 * (container.x - unfilledLength)
      : 0.5 * (container.y - unfilledLength);
    if (xFlag) {
      displayP1.x = offset;
      displayP2.x = unfilledLength + offset;
      dataP1.x = coordinate.p1.x;
      dataP2.x = coordinate.p2.x;
    } else {
      displayP1.y = offset;
      displayP2.y = unfilledLength + offset;
      dataP1.y = coordinate.p1.y;
      dataP2.y = coordinate.p2.y;
    }
    setDisplayRange({ p1: displayP1, p2: displayP2 });
    setDataRange({ p1: dataP1, p2: dataP2 });
  } else {
    setDisplayRange({ p1: displayP1, p2: displayP2 });
    setDataRange({ p1: dataP1, p2: dataP2 });
  }
}

数据映射

首先,根据页面展示范围及数据坐标范围,可以得到一个二元一次方程
常量1 * daraRange.P1.x + 常量2 = displayRange.P1.x
常量1 * dataRange.P2.x + 常量2 = displayRange.P2.x

如此就可以很轻松的得到,每个数据在当前地图区域上的实际坐标

// 获取坐标转换的因子, 因子说明见 getPosTransform 方法
function getCoordinateTransformFactor({
  pos1,
  pos2,
  coor1,
  coor2,
}: {
  pos1: number;
  pos2: number;
  coor1: number;
  coor2: number;
}) {
  const constantValue = (pos1 - pos2) / (coor1 - coor2);
  const scale = pos1 - constantValue * coor1;
  return { scale, constantValue };
}
 
function getPosTransform({
  curP,
  reverseLen,
  scale,
  constantValue,
}: {
  curP: number;
  reverseLen?: number;
  scale: number;
  constantValue: number;
}): number {
  /**
   * scale * coor1 + constantValue = pos1
   * scale * coor2 + constantValue = pos2
   * scale = (pos1 - pos2) / (coor1 - coor2)
   * constantValue = pos1 - constantValue * coor1
   * scale * curCoor + constantValue = curPos
   *
   * y axis => need to reverse
   * reverseLen is the length of the scaled length
   */
  let curPs = constantValue * curP + scale;
  if (reverseLen) {
    curPs = reverseLen - curPs;
  }
  return Number(curPs.toFixed(2));
}

最后再聚合一下,就OK了。

type TMapDataType = {
  originX: number
  originY: number
  x: number
  y: number
  value: number
}
// 获得实际渲染数据,结果中 x y 分别为实际页面上的 x y,originX originY 为原始的 x y
export function getTransData({
  initialData,
  displayRange,
  dataRange,
  containerSize,
  colorRange,
}: {
  initialData: any[];
  displayRange: { p1: TPoint; p2: TPoint; };
  dataRange: { p1: TPoint; p2: TPoint; };
  containerSize: TPoint;
  colorRange: number[];
}): { x: number; y: number; value: number }[] {
  const { constantValue: cx, scale: sx } = getCoordinateTransformFactor({
    coor1: dataRange.p1.x,
    pos1: displayRange.p1.x,
    coor2: dataRange.p2.x,
    pos2: displayRange.p2.x,
  });
  const { constantValue: cy, scale: sy } = getCoordinateTransformFactor({
    coor1: dataRange.p1.y,
    pos1: displayRange.p1.y,
    coor2: dataRange.p2.y,
    pos2: displayRange.p2.y,
  });
 
  let mapData:TMapDataType[] = [];
  const { x: x1, y: y1 } = dataRange.p1;
  const { x: x2, y: y2 } = dataRange.p2;
  const xMin = Math.min(x1, x2),
    xMax = Math.max(x1, x2),
    yMin = Math.min(y1, y2),
    yMax = Math.max(y1, y2);
  (initialData || []).forEach((d) => {
    const { x, y } = d;
    if (x >= xMin && x <= xMax && y >= yMin && y <= yMax) {
      mapData.push({
        originX: x,
        originY: y,
        x: getPosTrans({
          constantValue: cx,
          scale: sx,
          curP: x,
        }),
        y: getPosTrans({
          constantValue: cy,
          scale: sy,
          curP: y,
          reverseLen: containerSize.y,
        }),
        value: d.value,
      });
    }
  });
  return mapData;
}

如此,就能获得映射后的热力地图数据集。

const transData = getTransData({
  initialData: data,
  displayRange: displayRange,
  dataRange: dataRange,
  containerSize: getContainerSize(),
});

数据反映射

数据反映射,主要是用于一些tooltip,或是后续的一些选择区域之后,将热力图上的坐标转换为实际数据中的坐标。
具体方法跟映射差不多。

function getPosDeTrans({
  curP,
  reverseLen,
  scale,
  constantValue,
}: {
  curP: number;
  reverseLen?: number;
  scale: number;
  constantValue: number;
}): number {
  let t = curP;
  if (reverseLen) {
    t = reverseLen - t;
  }
  return transNumberFix2((t - scale) / constantValue); // 取二位小数,不再赘述
}

End

具体代码可以访问github,或者npm

任何讨论都可以email我喔,iyoungliu@163.com