Vizzuality/landgriffon

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

Summary

Maintainability
F
1 wk
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.
 *
 */

import { bounds } from '@mapbox/geo-viewport';
import { ascending, descending, extent, min } from 'd3-array';
import { nest } from 'd3-collection';
import { scaleLinear, scaleSqrt } from 'd3-scale';
import KDBush from 'kdbush';
import {
  createSelector,
  createSelectorCreator,
  defaultMemoize,
  ParametricSelector,
} from 'reselect';
import { alea } from 'seedrandom';
import { clusterLocations } from './cluster/cluster';
import {
  buildIndex,
  ClusterIndex,
  findAppropriateZoomLevel,
  makeLocationWeightGetter,
} from './cluster/ClusterIndex';
import getColors, {
  getColorsRGBA,
  getDiffColorsRGBA,
  getFlowColorScale,
  isDiffColors,
  isDiffColorsRGBA,
} from './colors';
import FlowMapAggregateAccessors from './FlowMapAggregateAccessors';
import { FlowMapState } from './FlowMapState';
import {
  getTimeGranularityByKey,
  getTimeGranularityByOrder,
  getTimeGranularityForDate,
  TimeGranularityKey,
} from './time';
import {
  AggregateFlow,
  Cluster,
  ClusterNode,
  CountByTime,
  FlowAccessors,
  FlowCirclesLayerAttributes,
  FlowLinesLayerAttributes,
  FlowMapData,
  FlowMapDataAccessors,
  isCluster,
  isLocationClusterNode,
  LayersData,
  LocationFilterMode,
  LocationTotals,
} from './types';
import { flatMap } from './util';

const MAX_CLUSTER_ZOOM_LEVEL = 20;
const NUMBER_OF_FLOWS_TO_DISPLAY = 5000;
type KDBushTree = any;

export type Selector<L, F, T> = ParametricSelector<FlowMapState, FlowMapData<L, F>, T>;

export default class FlowMapSelectors<L, F> {
  accessors: FlowMapAggregateAccessors<L, F>;

  constructor(accessors: FlowMapDataAccessors<L, F>) {
    this.accessors = new FlowMapAggregateAccessors(accessors);
    this.setAccessors(accessors);
  }

  setAccessors(accessors: FlowMapDataAccessors<L, F>) {
    this.accessors = new FlowMapAggregateAccessors(accessors);
  }

  getFetchedFlows = (state: FlowMapState, props: FlowMapData<L, F>) => props.flows;
  getFetchedLocations = (state: FlowMapState, props: FlowMapData<L, F>) => props.locations;
  getSelectedLocations = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.filterState.selectedLocations;
  getLocationFilterMode = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.filterState.locationFilterMode;
  getClusteringEnabled = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.clusteringEnabled;
  getLocationTotalsEnabled = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.locationTotalsEnabled;
  getZoom = (state: FlowMapState, props: FlowMapData<L, F>) => state.viewport.zoom;
  getViewport = (state: FlowMapState, props: FlowMapData<L, F>) => state.viewport;
  getSelectedTimeRange = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.filterState.selectedTimeRange;

  getColorSchemeKey: Selector<L, F, string | undefined> = (
    state: FlowMapState,
    props: FlowMapData<L, F>,
  ) => state.settingsState.colorScheme;

  getDarkMode: Selector<L, F, boolean> = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.darkMode;

  getFadeEnabled: Selector<L, F, boolean> = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.fadeEnabled;

  getFadeAmount: Selector<L, F, number> = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.fadeAmount;

  getAnimate: Selector<L, F, boolean> = (state: FlowMapState, props: FlowMapData<L, F>) =>
    state.settingsState.animationEnabled;

  getInvalidLocationIds: Selector<L, F, string[] | undefined> = createSelector(
    this.getFetchedLocations,
    (locations) => {
      if (!locations) return undefined;
      const invalid = [];
      for (const location of locations) {
        const id = this.accessors.getLocationId(location);
        const [lon, lat] = this.accessors.getLocationCentroid(location) || [NaN, NaN];
        if (!(-90 <= lat && lat <= 90) || !(-180 <= lon && lon <= 180)) {
          invalid.push(id);
        }
      }
      return invalid.length > 0 ? invalid : undefined;
    },
  );

  getLocations: Selector<L, F, L[] | undefined> = createSelector(
    this.getFetchedLocations,
    this.getInvalidLocationIds,
    (locations, invalidIds) => {
      if (!locations) return undefined;
      if (!invalidIds || invalidIds.length === 0) return locations;
      const invalid: any = new Set(invalidIds);
      return locations.filter(
        (location: L) => !invalid.has(this.accessors.getLocationId(location)),
      );
    },
  );

  getLocationIds: Selector<L, F, Set<string> | undefined> = createSelector(
    this.getLocations,
    (locations) => (locations ? new Set(locations.map(this.accessors.getLocationId)) : undefined),
  );

  getSelectedLocationsSet: Selector<L, F, Set<string> | undefined> = createSelector(
    this.getSelectedLocations,
    (ids) => (ids && ids.length > 0 ? new Set(ids) : undefined),
  );

  getSortedFlowsForKnownLocations: Selector<L, F, F[] | undefined> = createSelector(
    this.getFetchedFlows,
    this.getLocationIds,
    (flows, ids) => {
      if (!ids || !flows) return undefined;
      return flows
        .filter(
          (flow: F) =>
            ids.has(this.accessors.getFlowOriginId(flow)) &&
            ids.has(this.accessors.getFlowDestId(flow)),
        )
        .sort((a: F, b: F) =>
          descending(
            Math.abs(this.accessors.getFlowMagnitude(a)),
            Math.abs(this.accessors.getFlowMagnitude(b)),
          ),
        );
    },
  );

  getActualTimeExtent: Selector<L, F, [Date, Date] | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    (flows) => {
      if (!flows) return undefined;
      let start = null;
      let end = null;
      for (const flow of flows) {
        const time = this.accessors.getFlowTime(flow);
        if (time) {
          if (start == null || start > time) start = time;
          if (end == null || end < time) end = time;
        }
      }
      if (!start || !end) return undefined;
      return [start, end];
    },
  );

  getTimeGranularityKey: Selector<L, F, TimeGranularityKey | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    this.getActualTimeExtent,
    (flows, timeExtent) => {
      if (!flows || !timeExtent) return undefined;

      const minOrder = min(flows, (d) => {
        const t = this.accessors.getFlowTime(d);
        return t ? getTimeGranularityForDate(t).order : null;
      });
      if (minOrder == null) return undefined;
      const timeGranularity = getTimeGranularityByOrder(minOrder);
      return timeGranularity ? timeGranularity.key : undefined;
    },
  );

  getTimeExtent: Selector<L, F, [Date, Date] | undefined> = createSelector(
    this.getActualTimeExtent,
    this.getTimeGranularityKey,
    (timeExtent, timeGranularityKey) => {
      const timeGranularity = timeGranularityKey
        ? getTimeGranularityByKey(timeGranularityKey)
        : undefined;
      if (!timeExtent || !timeGranularity?.interval) return undefined;
      const { interval } = timeGranularity;
      return [timeExtent[0], interval.offset(interval.floor(timeExtent[1]), 1)];
    },
  );

  getSortedFlowsForKnownLocationsFilteredByTime: Selector<L, F, F[] | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    this.getTimeExtent,
    this.getSelectedTimeRange,
    (flows, timeExtent, timeRange) => {
      if (!flows) return undefined;
      if (
        !timeExtent ||
        !timeRange ||
        (timeExtent[0] === timeRange[0] && timeExtent[1] === timeRange[1])
      ) {
        return flows;
      }
      return flows.filter((flow) => {
        const time = this.accessors.getFlowTime(flow);
        return time && timeRange[0] <= time && time < timeRange[1];
      });
    },
  );

  getLocationsHavingFlows: Selector<L, F, L[] | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    this.getLocations,
    (flows, locations) => {
      if (!locations || !flows) return locations;
      const withFlows = new Set();
      for (const flow of flows) {
        withFlows.add(this.accessors.getFlowOriginId(flow));
        withFlows.add(this.accessors.getFlowDestId(flow));
      }
      return locations.filter((location: L) =>
        withFlows.has(this.accessors.getLocationId(location)),
      );
    },
  );

  getLocationsById: Selector<L, F, Map<string, L> | undefined> = createSelector(
    this.getLocationsHavingFlows,
    (locations) => {
      if (!locations) return undefined;
      return nest<L, L>()
        .key((d: L) => this.accessors.getLocationId(d))
        .rollup(([d]: any[]) => d)
        .map(locations) as any as Map<string, L>;
    },
  );

  getClusterIndex: Selector<L, F, ClusterIndex<F> | undefined> = createSelector(
    this.getLocationsHavingFlows,
    this.getLocationsById,
    this.getSortedFlowsForKnownLocations,
    (locations, locationsById, flows) => {
      if (!locations || !locationsById || !flows) return undefined;

      const getLocationWeight = makeLocationWeightGetter(
        flows,
        this.accessors.getFlowMapDataAccessors(),
      );
      const clusterLevels = clusterLocations(
        locations,
        this.accessors.getFlowMapDataAccessors(),
        getLocationWeight,
        {
          maxZoom: MAX_CLUSTER_ZOOM_LEVEL,
        },
      );
      const clusterIndex = buildIndex<F>(clusterLevels);
      const { getLocationName, getLocationClusterName } = this.accessors.getFlowMapDataAccessors();

      // Adding meaningful names
      const getName = (id: string) => {
        const loc = locationsById.get(id);
        if (loc) {
          return getLocationName ? getLocationName(loc) : this.accessors.getLocationId(loc) || id;
        }
        return `"${id}"`;
      };
      for (const level of clusterLevels) {
        for (const node of level.nodes) {
          // Here mutating the nodes (adding names)
          if (isCluster(node)) {
            const leaves = clusterIndex.expandCluster(node);

            leaves.sort((a, b) => descending(getLocationWeight(a), getLocationWeight(b)));

            if (getLocationClusterName) {
              node.name = getLocationClusterName(leaves);
            } else {
              const topId = leaves[0];
              const otherId = leaves.length === 2 ? leaves[1] : undefined;
              node.name = `"${getName(topId)}" and ${
                otherId ? `"${getName(otherId)}"` : `${leaves.length - 1} others`
              }`;
            }
          } else {
            (node as any).name = getName(node.id);
          }
        }
      }

      return clusterIndex;
    },
  );

  getAvailableClusterZoomLevels = createSelector(
    this.getClusterIndex,
    this.getSelectedLocations,
    (clusterIndex, selectedLocations): number[] | undefined => {
      if (!clusterIndex) {
        return undefined;
      }

      let maxZoom = Number.POSITIVE_INFINITY;
      let minZoom = Number.NEGATIVE_INFINITY;

      const adjust = (zoneId: string) => {
        const cluster = clusterIndex.getClusterById(zoneId);
        if (cluster) {
          minZoom = Math.max(minZoom, cluster.zoom);
          maxZoom = Math.min(maxZoom, cluster.zoom);
        } else {
          const zoom = clusterIndex.getMinZoomForLocation(zoneId);
          minZoom = Math.max(minZoom, zoom);
        }
      };

      if (selectedLocations) {
        for (const id of selectedLocations) {
          adjust(id);
        }
      }

      return clusterIndex.availableZoomLevels.filter(
        (level) => minZoom <= level && level <= maxZoom,
      );
    },
  );

  _getClusterZoom: Selector<L, F, number | undefined> = createSelector(
    this.getClusterIndex,
    this.getZoom,
    this.getAvailableClusterZoomLevels,
    (clusterIndex, mapZoom, availableClusterZoomLevels) => {
      if (!clusterIndex) return undefined;
      if (!availableClusterZoomLevels) {
        return undefined;
      }

      const clusterZoom = findAppropriateZoomLevel(availableClusterZoomLevels, mapZoom);
      return clusterZoom;
    },
  );

  getClusterZoom = (state: FlowMapState, props: FlowMapData<L, F>) => {
    const { settingsState } = state;
    if (!settingsState.clusteringEnabled) return undefined;
    if (settingsState.clusteringAuto || settingsState.clusteringLevel == null) {
      return this._getClusterZoom(state, props);
    }
    return settingsState.clusteringLevel;
  };

  getLocationsForSearchBox: Selector<L, F, (L | Cluster)[] | undefined> = createSelector(
    this.getClusteringEnabled,
    this.getLocationsHavingFlows,
    this.getSelectedLocations,
    this.getClusterZoom,
    this.getClusterIndex,
    (clusteringEnabled, locations, selectedLocations, clusterZoom, clusterIndex) => {
      if (!locations) return undefined;
      let result: (L | Cluster)[] = locations;
      // if (clusteringEnabled) {
      //   if (clusterIndex) {
      //     const zoomItems = clusterIndex.getClusterNodesFor(clusterZoom);
      //     if (zoomItems) {
      //       result = result.concat(zoomItems.filter(isCluster));
      //     }
      //   }
      // }

      if (result && clusterIndex && selectedLocations) {
        const toAppend = [];
        for (const id of selectedLocations) {
          const cluster = clusterIndex.getClusterById(id);
          if (
            cluster &&
            !result.find(
              (d) => (isLocationClusterNode(d) ? d.id : this.accessors.getLocationId(d)) === id,
            )
          ) {
            toAppend.push(cluster);
          }
        }
        if (toAppend.length > 0) {
          result = result.concat(toAppend);
        }
      }
      return result;
    },
  );

  getDiffMode: Selector<L, F, boolean> = createSelector(this.getFetchedFlows, (flows) => {
    if (flows && flows.find((f: F) => this.accessors.getFlowMagnitude(f) < 0)) {
      return true;
    }
    return false;
  });

  _getFlowMapColors = createSelector(
    this.getDiffMode,
    this.getColorSchemeKey,
    this.getDarkMode,
    this.getFadeEnabled,
    this.getFadeAmount,
    this.getAnimate,
    getColors,
  );

  getFlowMapColorsRGBA = createSelector(this._getFlowMapColors, (flowMapColors) => {
    return isDiffColors(flowMapColors)
      ? getDiffColorsRGBA(flowMapColors)
      : getColorsRGBA(flowMapColors);
  });

  getUnknownLocations: Selector<L, F, Set<string> | undefined> = createSelector(
    this.getLocationIds,
    this.getFetchedFlows,
    this.getSortedFlowsForKnownLocations,
    (ids, flows, flowsForKnownLocations) => {
      if (!ids || !flows) return undefined;
      if (flowsForKnownLocations && flows.length === flowsForKnownLocations.length)
        return undefined;
      const missing = new Set<string>();
      for (const flow of flows) {
        if (!ids.has(this.accessors.getFlowOriginId(flow)))
          missing.add(this.accessors.getFlowOriginId(flow));
        if (!ids.has(this.accessors.getFlowDestId(flow)))
          missing.add(this.accessors.getFlowDestId(flow));
      }
      return missing;
    },
  );

  getSortedAggregatedFilteredFlows: Selector<L, F, (F | AggregateFlow)[] | undefined> =
    createSelector(
      this.getClusterIndex,
      this.getClusteringEnabled,
      this.getSortedFlowsForKnownLocationsFilteredByTime,
      this.getClusterZoom,
      this.getTimeExtent,
      (clusterTree, isClusteringEnabled, flows, clusterZoom, timeExtent) => {
        if (!flows) return undefined;
        let aggregated: (F | AggregateFlow)[];
        if (isClusteringEnabled && clusterTree && clusterZoom != null) {
          aggregated = clusterTree.aggregateFlows(
            // TODO: aggregate across time
            // timeExtent != null
            //   ? aggregateFlows(flows) // clusterTree.aggregateFlows won't aggregate unclustered across time
            //   : flows,
            flows,
            clusterZoom,
            this.accessors.getFlowMapDataAccessors(),
          );
        } else {
          aggregated = aggregateFlows(flows, this.accessors.getFlowMapDataAccessors());
        }
        aggregated.sort((a, b) =>
          descending(
            Math.abs(this.accessors.getFlowMagnitude(a)),
            Math.abs(this.accessors.getFlowMagnitude(b)),
          ),
        );
        return aggregated;
      },
    );

  _getFlowMagnitudeExtent: Selector<L, F, [number, number] | undefined> = createSelector(
    this.getSortedAggregatedFilteredFlows,
    this.getSelectedLocationsSet,
    this.getLocationFilterMode,
    (flows, selectedLocationsSet, locationFilterMode) => {
      if (!flows) return undefined;
      let rv: [number, number] | undefined = undefined;
      for (const f of flows) {
        if (
          this.accessors.getFlowOriginId(f) !== this.accessors.getFlowDestId(f) &&
          this.isFlowInSelection(f, selectedLocationsSet, locationFilterMode)
        ) {
          const count = this.accessors.getFlowMagnitude(f);
          if (rv == null) {
            rv = [count, count];
          } else {
            if (count < rv[0]) rv[0] = count;
            if (count > rv[1]) rv[1] = count;
          }
        }
      }
      return rv;
    },
  );

  getExpandedSelectedLocationsSet: Selector<L, F, Set<string> | undefined> = createSelector(
    this.getClusteringEnabled,
    this.getSelectedLocationsSet,
    this.getClusterIndex,
    (clusteringEnabled, selectedLocations, clusterIndex) => {
      if (!selectedLocations || !clusterIndex) {
        return selectedLocations;
      }

      const result = new Set<string>();
      for (const locationId of selectedLocations) {
        const cluster = clusterIndex.getClusterById(locationId);
        if (cluster) {
          const expanded = clusterIndex.expandCluster(cluster);
          for (const id of expanded) {
            result.add(id);
          }
        } else {
          result.add(locationId);
        }
      }
      return result;
    },
  );

  getTotalCountsByTime: Selector<L, F, CountByTime[] | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    this.getTimeGranularityKey,
    this.getTimeExtent,
    this.getExpandedSelectedLocationsSet,
    this.getLocationFilterMode,
    (flows, timeGranularityKey, timeExtent, selectedLocationSet, locationFilterMode) => {
      const timeGranularity = timeGranularityKey
        ? getTimeGranularityByKey(timeGranularityKey)
        : undefined;
      if (!flows || !timeGranularity || !timeExtent) return undefined;
      const byTime = flows.reduce((m, flow) => {
        if (this.isFlowInSelection(flow, selectedLocationSet, locationFilterMode)) {
          const key = timeGranularity.interval(this.accessors.getFlowTime(flow)).getTime();
          m.set(key, (m.get(key) ?? 0) + this.accessors.getFlowMagnitude(flow));
        }
        return m;
      }, new Map<number, number>());

      return Array.from(byTime.entries()).map(([millis, count]) => ({
        time: new Date(millis),
        count,
      }));
    },
  );

  getMaxLocationCircleSize: Selector<L, F, number> = createSelector(
    this.getLocationTotalsEnabled,
    (locationTotalsEnabled) => (locationTotalsEnabled ? 17 : 1),
  );

  getViewportBoundingBox: Selector<L, F, [number, number, number, number]> = createSelector(
    this.getViewport,
    this.getMaxLocationCircleSize,
    (viewport, maxLocationCircleSize) => {
      const pad = maxLocationCircleSize;
      return bounds(
        [viewport.longitude, viewport.latitude],
        viewport.zoom,
        [viewport.width + pad * 2, viewport.height + pad * 2],
        512,
      );
    },
  );

  getLocationsForZoom: Selector<L, F, L[] | ClusterNode[] | undefined> = createSelector(
    this.getClusteringEnabled,
    this.getLocationsHavingFlows,
    this.getClusterIndex,
    this.getClusterZoom,
    (clusteringEnabled, locationsHavingFlows, clusterIndex, clusterZoom) => {
      if (clusteringEnabled && clusterIndex) {
        return clusterIndex.getClusterNodesFor(clusterZoom);
      } else {
        return locationsHavingFlows;
      }
    },
  );

  getLocationTotals: Selector<L, F, Map<string, LocationTotals> | undefined> = createSelector(
    this.getLocationsForZoom,
    this.getSortedAggregatedFilteredFlows,
    this.getSelectedLocationsSet,
    this.getLocationFilterMode,
    (locations, flows, selectedLocationsSet, locationFilterMode) => {
      if (!flows) return undefined;
      const totals = new Map<string, LocationTotals>();
      const add = (id: string, d: Partial<LocationTotals>): LocationTotals => {
        const rv = totals.get(id) ?? {
          incomingCount: 0,
          outgoingCount: 0,
          internalCount: 0,
        };
        if (d.incomingCount != null) rv.incomingCount += d.incomingCount;
        if (d.outgoingCount != null) rv.outgoingCount += d.outgoingCount;
        if (d.internalCount != null) rv.internalCount += d.internalCount;
        return rv;
      };
      for (const f of flows) {
        if (this.isFlowInSelection(f, selectedLocationsSet, locationFilterMode)) {
          const originId = this.accessors.getFlowOriginId(f);
          const destId = this.accessors.getFlowDestId(f);
          const count = this.accessors.getFlowMagnitude(f);
          if (originId === destId) {
            totals.set(originId, add(originId, { internalCount: count }));
          } else {
            totals.set(originId, add(originId, { outgoingCount: count }));
            totals.set(destId, add(destId, { incomingCount: count }));
          }
        }
      }
      return totals;
    },
  );

  getLocationsTree: Selector<L, F, KDBushTree> = createSelector(
    this.getLocationsForZoom,
    (locations) => {
      if (!locations) {
        return undefined;
      }
      return new KDBush(
        // @ts-ignore
        locations,
        (location: L | ClusterNode) =>
          lngX(
            isLocationClusterNode(location)
              ? location.centroid[0]
              : this.accessors.getLocationCentroid(location)[0],
          ),
        (location: L | ClusterNode) =>
          latY(
            isLocationClusterNode(location)
              ? location.centroid[1]
              : this.accessors.getLocationCentroid(location)[1],
          ),
      );
    },
  );

  _getLocationIdsInViewport: Selector<L, F, Set<string> | undefined> = createSelector(
    this.getLocationsTree,
    this.getViewportBoundingBox,
    (tree: KDBushTree, bbox: [number, number, number, number]) => {
      const ids = this._getLocationsInBboxIndices(tree, bbox);
      if (ids) {
        return new Set(ids.map((idx: number) => tree.points[idx].id) as Array<string>);
      }
      return undefined;
    },
  );

  getLocationIdsInViewport: Selector<L, F, Set<string> | undefined> =
    // @ts-ignore
    createSelectorCreator<Set<string> | undefined>(
      // @ts-ignore
      defaultMemoize,
      (s1: Set<string> | undefined, s2: Set<string> | undefined, index: number) => {
        if (s1 === s2) return true;
        if (s1 == null || s2 == null) return false;
        if (s1.size !== s2.size) return false;
        for (const item of s1) if (!s2.has(item)) return false;
        return true;
      },
    )(this._getLocationIdsInViewport, (locationIds: Set<string> | undefined) => {
      if (!locationIds) return undefined;
      return locationIds;
    });

  getTotalUnfilteredCount: Selector<L, F, number | undefined> = createSelector(
    this.getSortedFlowsForKnownLocations,
    (flows) => {
      if (!flows) return undefined;
      return flows.reduce((m, flow) => m + this.accessors.getFlowMagnitude(flow), 0);
    },
  );

  getTotalFilteredCount: Selector<L, F, number | undefined> = createSelector(
    this.getSortedAggregatedFilteredFlows,
    this.getSelectedLocationsSet,
    this.getLocationFilterMode,
    (flows, selectedLocationSet, locationFilterMode) => {
      if (!flows) return undefined;
      const count = flows.reduce((m, flow) => {
        if (this.isFlowInSelection(flow, selectedLocationSet, locationFilterMode)) {
          return m + this.accessors.getFlowMagnitude(flow);
        }
        return m;
      }, 0);
      return count;
    },
  );

  _getLocationTotalsExtent: Selector<L, F, [number, number] | undefined> = createSelector(
    this.getLocationTotals,
    (locationTotals) => calcLocationTotalsExtent(locationTotals, undefined),
  );

  _getLocationTotalsForViewportExtent: Selector<L, F, [number, number] | undefined> =
    createSelector(
      this.getLocationTotals,
      this.getLocationIdsInViewport,
      (locationTotals, locationsInViewport) =>
        calcLocationTotalsExtent(locationTotals, locationsInViewport),
    );

  getLocationTotalsExtent = (
    state: FlowMapState,
    props: FlowMapData<L, F>,
  ): [number, number] | undefined => {
    if (state.settingsState.adaptiveScalesEnabled) {
      return this._getLocationTotalsForViewportExtent(state, props);
    } else {
      return this._getLocationTotalsExtent(state, props);
    }
  };

  getFlowsForFlowMapLayer: Selector<L, F, (F | AggregateFlow)[] | undefined> = createSelector(
    this.getSortedAggregatedFilteredFlows,
    this.getLocationIdsInViewport,
    this.getSelectedLocationsSet,
    this.getLocationFilterMode,
    (flows, locationIdsInViewport, selectedLocationsSet, locationFilterMode) => {
      if (!flows || !locationIdsInViewport) return undefined;
      const picked: (F | AggregateFlow)[] = [];
      let pickedCount = 0;
      for (const flow of flows) {
        const origin = this.accessors.getFlowOriginId(flow);
        const dest = this.accessors.getFlowDestId(flow);
        if (locationIdsInViewport.has(origin) || locationIdsInViewport.has(dest)) {
          if (this.isFlowInSelection(flow, selectedLocationsSet, locationFilterMode)) {
            if (origin !== dest) {
              // exclude self-loops
              picked.push(flow);
              pickedCount++;
            }
          }
        }
        // Only keep top
        if (pickedCount > NUMBER_OF_FLOWS_TO_DISPLAY) break;
      }
      // assuming they are sorted in descending order,
      // we need ascending for rendering
      return picked.reverse();
    },
  );

  getFlowMagnitudeExtent(
    state: FlowMapState,
    props: FlowMapData<L, F>,
  ): [number, number] | undefined {
    if (state.settingsState.adaptiveScalesEnabled) {
      const flows = this.getFlowsForFlowMapLayer(state, props);
      if (flows) {
        const rv = extent(flows, this.accessors.getFlowMagnitude);
        return rv[0] !== undefined && rv[1] !== undefined ? rv : undefined;
      } else {
        return undefined;
      }
    } else {
      return this._getFlowMagnitudeExtent(state, props);
    }
  }

  getLocationMaxAbsTotalGetter = createSelector(this.getLocationTotals, (locationTotals) => {
    return (locationId: string) => {
      const total = locationTotals?.get(locationId);
      if (!total) return undefined;
      return Math.max(
        Math.abs(total.incomingCount + total.internalCount),
        Math.abs(total.outgoingCount + total.internalCount),
      );
    };
  });

  getFlowThicknessScale = (state: FlowMapState, props: FlowMapData<L, F>) => {
    const magnitudeExtent = this.getFlowMagnitudeExtent(state, props);
    if (!magnitudeExtent) return undefined;
    return scaleLinear()
      .range([0.025, 0.5])
      .domain([
        0,
        // should support diff mode too
        Math.max.apply(
          null,
          magnitudeExtent.map((x: number | undefined) => Math.abs(x || 0)),
        ),
      ]);
  };

  getCircleSizeScale = (state: FlowMapState, props: FlowMapData<L, F>) => {
    const maxLocationCircleSize = this.getMaxLocationCircleSize(state, props);
    const { locationTotalsEnabled } = state.settingsState;
    if (!locationTotalsEnabled) {
      return () => maxLocationCircleSize;
    }

    const locationTotalsExtent = this.getLocationTotalsExtent(state, props);
    if (!locationTotalsExtent) return undefined;
    return scaleSqrt()
      .range([0, maxLocationCircleSize])
      .domain([
        0,
        // should support diff mode too
        Math.max.apply(
          null,
          locationTotalsExtent.map((x: number | undefined) => Math.abs(x || 0)),
        ),
      ]);
  };

  getInCircleSizeGetter = createSelector(
    this.getCircleSizeScale,
    this.getLocationTotals,
    (circleSizeScale, locationTotals) => {
      return (locationId: string) => {
        // const total = locationTotals?.get(locationId);
        // if (total && circleSizeScale) {
        //   return circleSizeScale(Math.abs(total.incomingCount + total.internalCount)) || 0;
        // }
        return 4;
      };
    },
  );

  getOutCircleSizeGetter = createSelector(
    this.getCircleSizeScale,
    this.getLocationTotals,
    (circleSizeScale, locationTotals) => {
      return (locationId: string) => {
        // const total = locationTotals?.get(locationId);
        // if (total && circleSizeScale) {
        //   return circleSizeScale(Math.abs(total.outgoingCount + total.internalCount)) || 0;
        // }
        return 4;
      };
    },
  );

  getSortedLocationsForZoom: Selector<L, F, L[] | ClusterNode[] | undefined> = createSelector(
    this.getLocationsForZoom,
    this.getInCircleSizeGetter,
    this.getOutCircleSizeGetter,
    (locations, getInCircleSize, getOutCircleSize) => {
      if (!locations) return undefined;
      const nextLocations = [...locations] as L[] | ClusterNode[];
      return nextLocations.sort((a, b) => {
        const idA = this.accessors.getLocationId(a);
        const idB = this.accessors.getLocationId(b);
        return ascending(
          Math.max(getInCircleSize(idA), getOutCircleSize(idA)),
          Math.max(getInCircleSize(idB), getOutCircleSize(idB)),
        );
      });
    },
  );

  getLocationsForFlowMapLayer: Selector<L, F, Array<L | ClusterNode> | undefined> = createSelector(
    this.getSortedLocationsForZoom,
    // this.getLocationIdsInViewport,
    (
      locations,
      // locationIdsInViewport
    ) => {
      // if (!locations) return undefined;
      // if (!locationIdsInViewport) return locations;
      // if (locationIdsInViewport.size === locations.length) return locations;
      // const filtered = [];
      // for (const loc of locations) {
      //   if (locationIdsInViewport.has(loc.id)) {
      //     filtered.push(loc);
      //   }
      // }
      // return filtered;
      // @ts-ignore
      // return locations.filter(
      //   (loc: L | ClusterNode) => locationIdsInViewport!.has(loc.id)
      // );
      // TODO: return location in viewport + "connected" ones
      return locations;
    },
  );

  getLocationsForFlowMapLayerById: Selector<L, F, Map<string, L | ClusterNode> | undefined> =
    createSelector(this.getLocationsForFlowMapLayer, (locations) => {
      if (!locations) return undefined;
      return locations.reduce((m, d) => (m.set(this.accessors.getLocationId(d), d), m), new Map());
    });

  prepareLayersData(state: FlowMapState, props: FlowMapData<L, F>): LayersData {
    const locations = this.getLocationsForFlowMapLayer(state, props) || [];
    const flows = this.getFlowsForFlowMapLayer(state, props) || [];
    const { getFlowOriginId, getFlowDestId, getFlowMagnitude, getLocationId, getLocationCentroid } =
      this.accessors;

    const flowMapColors = this.getFlowMapColorsRGBA(state, props);
    const { settingsState } = state;

    const locationsById = this.getLocationsForFlowMapLayerById(state, props);
    const getCentroid = (id: string) => {
      const loc = locationsById?.get(id);
      return loc ? getLocationCentroid(loc) : [0, 0];
    };

    const locationIdsInViewport = this.getLocationIdsInViewport(state, props);
    const getInCircleSize = this.getInCircleSizeGetter(state, props);
    const getOutCircleSize = this.getOutCircleSizeGetter(state, props);

    const flowThicknessScale = this.getFlowThicknessScale(state, props);

    const flowMagnitudeExtent = extent(flows, (f) => getFlowMagnitude(f)) as [number, number];
    const flowColorScale = getFlowColorScale(flowMapColors, flowMagnitudeExtent, false);

    const circlePositions = new Float32Array(flatMap(locations, getLocationCentroid));

    // TODO: diff mode
    const circleColor = isDiffColorsRGBA(flowMapColors)
      ? flowMapColors.positive.locationCircles.inner
      : flowMapColors.locationCircles.inner;

    const circleColors = new Uint8Array(flatMap(locations, (d) => circleColor));
    const inCircleRadii = new Float32Array(
      locations.map((loc) => {
        const id = getLocationId(loc);
        return locationIdsInViewport?.has(id) ? getInCircleSize(id) : 1.0;
      }),
    );
    const outCircleRadii = new Float32Array(
      locations.map((loc) => {
        const id = getLocationId(loc);
        return locationIdsInViewport?.has(id) ? getOutCircleSize(id) : 1.0;
      }),
    );

    const sourcePositions = new Float32Array(
      flatMap(flows, (d: F | AggregateFlow) => getCentroid(getFlowOriginId(d))),
    );
    const targetPositions = new Float32Array(
      flatMap(flows, (d: F | AggregateFlow) => getCentroid(getFlowDestId(d))),
    );
    const thicknesses = new Float32Array(
      flows.map((d: F | AggregateFlow) =>
        flowThicknessScale ? flowThicknessScale(getFlowMagnitude(d)) || 0 : 0,
      ),
      // flows.map(() => 0.1),
    );
    const endpointOffsets = new Float32Array(
      flatMap(flows, (d: F | AggregateFlow) => {
        const originId = getFlowOriginId(d);
        const destId = getFlowDestId(d);
        return [
          Math.max(getInCircleSize(originId), getOutCircleSize(originId)),
          Math.max(getInCircleSize(destId), getOutCircleSize(destId)),
        ];
      }),
    );
    const flowLineColors = new Uint8Array(
      flatMap(flows, (f: F | AggregateFlow) => flowColorScale(getFlowMagnitude(f))),
      // flatMap(flows, (f: F | AggregateFlow) => [0, 0, 0, 255]),
    );

    const staggeringValues = settingsState.animationEnabled
      ? new Float32Array(
          flows.map((f: F | AggregateFlow) =>
            // @ts-ignore
            new alea(`${getFlowOriginId(f)}-${getFlowDestId(f)}`)(),
          ),
        )
      : undefined;

    return {
      circleAttributes: {
        length: locations.length,
        attributes: {
          getPosition: { value: circlePositions, size: 2 },
          getColor: { value: circleColors, size: 4 },
          getInRadius: { value: inCircleRadii, size: 1 },
          getOutRadius: { value: outCircleRadii, size: 1 },
        },
      },
      lineAttributes: {
        length: flows.length,
        attributes: {
          getSourcePosition: { value: sourcePositions, size: 2 },
          getTargetPosition: { value: targetPositions, size: 2 },
          getThickness: { value: thicknesses, size: 1 },
          getColor: { value: flowLineColors, size: 4 },
          getEndpointOffsets: { value: endpointOffsets, size: 2 },
          ...(staggeringValues ? { getStaggering: { value: staggeringValues, size: 1 } } : {}),
        },
      },
    };
  }

  getLocationsInBbox(
    tree: KDBushTree,
    bbox: [number, number, number, number],
  ): Array<L> | undefined {
    if (!tree) return undefined;
    return this._getLocationsInBboxIndices(tree, bbox).map(
      (idx: number) => tree.points[idx],
    ) as Array<L>;
  }

  _getLocationsInBboxIndices(tree: KDBushTree, bbox: [number, number, number, number]) {
    if (!tree) return undefined;
    const [lon1, lat1, lon2, lat2] = bbox;
    const [x1, y1, x2, y2] = [lngX(lon1), latY(lat1), lngX(lon2), latY(lat2)];
    return tree.range(Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2));
  }

  isFlowInSelection(
    flow: F | AggregateFlow,
    selectedLocationsSet: Set<string> | undefined,
    locationFilterMode: LocationFilterMode,
  ) {
    const origin = this.accessors.getFlowOriginId(flow);
    const dest = this.accessors.getFlowDestId(flow);
    if (selectedLocationsSet) {
      switch (locationFilterMode) {
        case LocationFilterMode.ALL:
          return selectedLocationsSet.has(origin) || selectedLocationsSet.has(dest);
        case LocationFilterMode.BETWEEN:
          return selectedLocationsSet.has(origin) && selectedLocationsSet.has(dest);
        case LocationFilterMode.INCOMING:
          return selectedLocationsSet.has(dest);
        case LocationFilterMode.OUTGOING:
          return selectedLocationsSet.has(origin);
      }
    }
    return true;
  }

  // calcLocationTotals(
  //   locations: (L | ClusterNode)[],
  //   flows: F[],
  // ): LocationsTotals {
  //   return flows.reduce(
  //     (acc: LocationsTotals, curr) => {
  //       const originId = this.accessors.getFlowOriginId(curr);
  //       const destId = this.accessors.getFlowDestId(curr);
  //       const magnitude = this.accessors.getFlowMagnitude(curr);
  //       if (originId === destId) {
  //         acc.internal[originId] = (acc.internal[originId] || 0) + magnitude;
  //       } else {
  //         acc.outgoing[originId] = (acc.outgoing[originId] || 0) + magnitude;
  //         acc.incoming[destId] = (acc.incoming[destId] || 0) + magnitude;
  //       }
  //       return acc;
  //     },
  //     {incoming: {}, outgoing: {}, internal: {}},
  //   );
  // }
}

function calcLocationTotalsExtent(
  locationTotals: Map<string, LocationTotals> | undefined,
  locationIdsInViewport: Set<string> | undefined,
) {
  if (!locationTotals) return undefined;
  let rv: [number, number] | undefined = undefined;
  for (const [id, { incomingCount, outgoingCount, internalCount }] of locationTotals.entries()) {
    if (locationIdsInViewport == null || locationIdsInViewport.has(id)) {
      const lo = Math.min(
        incomingCount + internalCount,
        outgoingCount + internalCount,
        internalCount,
      );
      const hi = Math.max(
        incomingCount + internalCount,
        outgoingCount + internalCount,
        internalCount,
      );
      if (!rv) {
        rv = [lo, hi];
      } else {
        if (lo < rv[0]) rv[0] = lo;
        if (hi > rv[1]) rv[1] = hi;
      }
    }
  }
  return rv;
}

// 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 aggregateFlows<F>(flows: F[], flowAccessors: FlowAccessors<F>): AggregateFlow[] {
  // Sum up flows with same origin, dest
  const byOriginDest = nest<F, AggregateFlow>()
    .key(flowAccessors.getFlowOriginId)
    .key(flowAccessors.getFlowDestId)
    .rollup((ff: F[]) => {
      const origin = flowAccessors.getFlowOriginId(ff[0]);
      const dest = flowAccessors.getFlowDestId(ff[0]);
      // const color = ff[0].color;
      const rv: AggregateFlow = {
        aggregate: true,
        origin,
        dest,
        count: ff.reduce((m, f) => {
          const count = flowAccessors.getFlowMagnitude(f);
          if (count) {
            if (!isNaN(count) && isFinite(count)) return m + count;
          }
          return m;
        }, 0),
        // time: undefined,
      };
      // if (color) rv.color = color;
      return rv;
    })
    .entries(flows);
  const rv: AggregateFlow[] = [];
  for (const { values } of byOriginDest) {
    for (const { value } of values) {
      rv.push(value);
    }
  }
  return rv;
}

/**
 * This is used to augment hover picking info so that we can displace location tooltip
 * @param circleAttributes
 * @param index
 */
export function getOuterCircleRadiusByIndex(
  circleAttributes: FlowCirclesLayerAttributes,
  index: number,
): number {
  const { getInRadius, getOutRadius } = circleAttributes.attributes;
  return Math.max(getInRadius.value[index], getOutRadius.value[index]);
}

export function getLocationCentroidByIndex(
  circleAttributes: FlowCirclesLayerAttributes,
  index: number,
): [number, number] {
  const { getPosition } = circleAttributes.attributes;
  return [getPosition.value[index * 2], getPosition.value[index * 2 + 1]];
}

export function getFlowLineAttributesByIndex(
  lineAttributes: FlowLinesLayerAttributes,
  index: number,
): FlowLinesLayerAttributes {
  const {
    getColor,
    getEndpointOffsets,
    getSourcePosition,
    getTargetPosition,
    getThickness,
    getStaggering,
  } = lineAttributes.attributes;
  return {
    length: 1,
    attributes: {
      getColor: {
        value: getColor.value.subarray(index * 4, (index + 1) * 4),
        size: 4,
      },
      getEndpointOffsets: {
        value: getEndpointOffsets.value.subarray(index * 2, (index + 1) * 2),
        size: 2,
      },
      getSourcePosition: {
        value: getSourcePosition.value.subarray(index * 2, (index + 1) * 2),
        size: 2,
      },
      getTargetPosition: {
        value: getTargetPosition.value.subarray(index * 2, (index + 1) * 2),
        size: 2,
      },
      getThickness: {
        value: getThickness.value.subarray(index, index + 1),
        size: 1,
      },
      ...(getStaggering
        ? {
            getStaggering: {
              value: getStaggering.value.subarray(index, index + 1),
              size: 1,
            },
          }
        : undefined),
    },
  };
}