Vizzuality/landgriffon

View on GitHub
cookie-traceability/src/lib/flowmap/data/cluster/cluster.ts

Summary

Maintainability
D
1 day
Test Coverage
/*
 * Copyright 2022 FlowmapBlue
 * Copyright 2018-2020 Teralytics, modified by FlowmapBlue
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */

/**
 * The code in this file is a based on https://github.com/mapbox/supercluster
 */

// ISC License
//
// Copyright (c) 2016, Mapbox
//
// Permission to use, copy, modify, and/or distribute this software for any purpose
// with or without fee is hereby granted, provided that the above copyright notice
// and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.

import { rollup } from 'd3-array';
import KDBush from 'kdbush';
import { LocationWeightGetter } from './ClusterIndex';
import { Cluster, ClusterLevel, ClusterNode, LocationAccessors } from '../types';

export interface Options {
  minZoom: number; // min zoom to generate clusters on
  maxZoom: number; // max zoom level to cluster the points on
  radius: number; // cluster radius in pixels
  extent: number; // tile extent (radius is calculated relative to it)
  nodeSize: number; // size of the KD-tree leaf node, affects performance
  makeClusterName: (id: number, numPoints: number) => string | undefined;
  makeClusterId: (id: number) => string;
}

const defaultOptions: Options = {
  minZoom: 0,
  maxZoom: 16,
  radius: 40,
  extent: 512,
  nodeSize: 64,
  makeClusterName: (id: number, numPoints: number) => undefined,
  makeClusterId: (id: number) => `{[${id}]}`,
};

interface BasePoint {
  x: number; // projected point coordinates
  y: number;
  weight: number;
  zoom: number; // the last zoom the point was processed at
  parentId: number; // parent cluster id
}

interface LeafPoint extends BasePoint {
  index: number; // index of the source feature in the original input array,
}

interface ClusterPoint extends BasePoint {
  id: number;
  numPoints: number;
}

type Point = LeafPoint | ClusterPoint;

export function isLeafPoint(p: Point): p is LeafPoint {
  const { index } = p as LeafPoint;
  return index != null;
}

export function isClusterPoint(p: Point): p is ClusterPoint {
  const { id } = p as ClusterPoint;
  return id != null;
}

type ZoomLevelKDBush = any;

export function clusterLocations<L>(
  locations: L[],
  locationAccessors: LocationAccessors<L>,
  getLocationWeight: LocationWeightGetter,
  options?: Partial<Options>,
): ClusterLevel[] {
  const { getLocationCentroid, getLocationId } = locationAccessors;
  const opts = {
    ...defaultOptions,
    ...options,
  };
  const { minZoom, maxZoom, nodeSize, makeClusterName, makeClusterId } = opts;

  const trees = new Array<ZoomLevelKDBush>(maxZoom + 1);

  // generate a cluster object for each point and index input points into a KD-tree
  let clusters = new Array<Point>();
  for (let i = 0; i < locations.length; i++) {
    const [x, y] = getLocationCentroid(locations[i]);
    clusters.push({
      x: lngX(x), // projected point coordinates
      y: latY(y),
      weight: getLocationWeight(getLocationId(locations[i])),
      zoom: Infinity, // the last zoom the point was processed at
      index: i, // index of the source feature in the original input array,
      parentId: -1, // parent cluster id
    });
  }
  trees[maxZoom + 1] = new KDBush(clusters, getX, getY, nodeSize, Float32Array);

  // cluster points on max zoom, then cluster the results on previous zoom, etc.;
  // results in a cluster hierarchy across zoom levels
  for (let z = maxZoom; z >= minZoom; z--) {
    // create a new set of clusters for the zoom and index them with a KD-tree
    clusters = cluster(clusters, z, trees[z + 1], opts);
    trees[z] = new KDBush(clusters, getX, getY, nodeSize, Float32Array);
  }

  if (trees.length === 0) {
    return [];
  }
  const numbersOfClusters = trees.map((d) => d.points.length);
  const maxAvailZoom = numbersOfClusters.indexOf(numbersOfClusters[numbersOfClusters.length - 1]);
  const minAvailZoom = Math.min(maxAvailZoom, numbersOfClusters.lastIndexOf(numbersOfClusters[0]));

  const clusterLevels = new Array<ClusterLevel>();
  for (let zoom = minAvailZoom; zoom <= maxAvailZoom; zoom++) {
    let childrenByParent: Map<number, string[]> | undefined;
    const tree = trees[zoom];
    if (zoom < maxAvailZoom) {
      childrenByParent = rollup<Point, string[], number>(
        trees[zoom + 1].points,
        (points: any[]) =>
          points.map((p: any) => (p.id ? makeClusterId(p.id) : getLocationId(locations[p.index]))),
        (point: any) => point.parentId,
      );
    }

    const nodes: ClusterNode[] = [];
    for (const point of tree.points) {
      const { x, y, numPoints } = point;
      if (isLeafPoint(point)) {
        const location = locations[point.index];
        nodes.push({
          id: getLocationId(location),
          zoom,
          centroid: getLocationCentroid(location),
        });
      } else if (isClusterPoint(point)) {
        const { id } = point;
        const children = childrenByParent && childrenByParent.get(id);
        if (!children) {
          throw new Error(`Cluster ${id} doesn't have children`);
        }
        nodes.push({
          id: makeClusterId(id),
          name: makeClusterName(id, numPoints),
          zoom,
          centroid: [xLng(x), yLat(y)] as [number, number],
          children,
        } as Cluster);
      }
    }
    clusterLevels.push({
      zoom,
      nodes,
    });
  }
  return clusterLevels;
}

function createCluster(
  x: number,
  y: number,
  id: number,
  numPoints: number,
  weight: number,
): ClusterPoint {
  return {
    x, // weighted cluster center
    y,
    zoom: Infinity, // the last zoom the cluster was processed at
    id, // encodes index of the first child of the cluster and its zoom level
    parentId: -1, // parent cluster id
    numPoints,
    weight,
  };
}

function cluster(points: Point[], zoom: number, tree: ZoomLevelKDBush, options: Options) {
  const clusters: Point[] = [];
  const { radius, extent } = options;
  const r = radius / (extent * Math.pow(2, zoom));

  // loop through each point
  for (let i = 0; i < points.length; i++) {
    const p = points[i];
    // if we've already visited the point at this zoom level, skip it
    if (p.zoom <= zoom) {
      continue;
    }
    p.zoom = zoom;

    // find all nearby points
    const neighborIds = tree.within(p.x, p.y, r);

    let weight = p.weight || 1;
    let numPoints = isClusterPoint(p) ? p.numPoints : 1;
    let wx = p.x * weight;
    let wy = p.y * weight;

    // encode both zoom and point index on which the cluster originated
    const id = (i << 5) + (zoom + 1);

    for (const neighborId of neighborIds) {
      const b = tree.points[neighborId];
      // filter out neighbors that are already processed
      if (b.zoom <= zoom) {
        continue;
      }
      b.zoom = zoom; // save the zoom (so it doesn't get processed twice)

      const weight2 = b.weight || 1;
      const numPoints2 = b.numPoints || 1;
      wx += b.x * weight2; // accumulate coordinates for calculating weighted center
      wy += b.y * weight2;

      weight += weight2;
      numPoints += numPoints2;
      b.parentId = id;
    }

    if (numPoints === 1) {
      clusters.push(p);
    } else {
      p.parentId = id;
      clusters.push(createCluster(wx / weight, wy / weight, id, numPoints, weight));
    }
  }

  return clusters;
}

// spherical mercator to longitude/latitude
function xLng(x: number) {
  return (x - 0.5) * 360;
}

function yLat(y: number) {
  const y2 = ((180 - y * 360) * Math.PI) / 180;
  return (360 * Math.atan(Math.exp(y2))) / Math.PI - 90;
}

// longitude/latitude to spherical mercator in [0..1] range
function lngX(lng: number) {
  return lng / 360 + 0.5;
}

function latY(lat: number) {
  const sin = Math.sin((lat * Math.PI) / 180);
  const y = 0.5 - (0.25 * Math.log((1 + sin) / (1 - sin))) / Math.PI;
  return y < 0 ? 0 : y > 1 ? 1 : y;
}

function getX(p: Point) {
  return p.x;
}

function getY(p: Point) {
  return p.y;
}