Source

src/hooks/useMapData.js

import { useMemo, useState } from 'react'
import useLoadData from './useLoadData'
import useGetBins from './useGetBins'
import useLisaMap from './useLisaMap'
import useCartogramMap from './useCartogramMap'
import { useGeoda } from '../contexts/Geoda'
import { getVarId, getDataForBins } from '../utils'
import { colorScales } from '../config/scales'

const maxDesirableHeight = 500_000

/**
 * Simple function to quicky convert a value to color
 *
 * @category Utils/Map
 * @param {number} val Value to get color for
 * @param {number[]} breaks Breaks for the color scale, a series of numbers
 * @param {Array[]} colors Array of RGB values, indexed to breaks
 * @param {boolean} useZero If true, separate zero values
 * @returns {number[]} RGB color value
 */
const getContinuousColor = (val, breaks, colors, useZero = false) => {
    if (useZero && val === 0) return [240, 240, 240]
    if (val === null || val === undefined) return [50, 50, 50]
    for (let i = 0; i < breaks.length; i++) {
        if (val <= breaks[i]) return colors[i]
    }
    return colors[colors.length - 1]
}
/**
 * @typedef {Object} MapData
 * @property {number} value Value for current variable
 * @property {number[]} color RGB color value
 */

/** @typedef {Object<string, MapData>} JoinData */

/**
 * Generates value and color dictionary keyed to GEOIDs for use on the map.
 * Returns positional array of join data (see {@link JoinData}) and height
 * multiplier for Z-index, when using.
 *
 * @category Utils/Map
 * @param {Object} props
 * @param {Array[]} props.binData - 1D array of data to be binned
 * @param {number[]} props.bins - List of numeric breaks to use
 * @param {number[]} props.lisaData - Array of lisa cluster values
 * @param {Object} props.mapParams - Object of map parameters - see
 *   MapParamsSpec {@link /src/stores/paramsStore/types.ts}
 * @param {Object} props.order - Ordered GEOID list {[key: index]: GEOID:
 *   number}
 * @param {boolean} props.dataReady - Flag to indicate if data is ready
 * @param {boolean} props.shouldSeparateZero - Flag to indicate if zero values
 *   should be separated
 * @returns {Array} Positional data return [ joinData - See {@link JoinData},
 *   heightScale: number]
 */
const generateJoinData = ({
    binData,
    bins,
    lisaData,
    mapParams,
    order,
    dataReady,
    shouldSeparateZero = false,
}) => {
    if (
        !dataReady ||
        (mapParams.mapType !== 'lisa' && !bins.breaks) ||
        (mapParams.mapType === 'lisa' && !lisaData.length)
    )
        return [{}, undefined]
    const geoids = Object.values(order)
    let joinData = {}
    if (mapParams.mapType === 'lisa') {
        for (let i = 0; i < geoids.length; i++) {
            joinData[geoids[i]] = {
                color: colorScales.lisa[lisaData[i]],
                value: binData[i],
            }
        }
    } else {
        for (let i = 0; i < geoids.length; i++) {
            joinData[geoids[i]] = {
                color: getContinuousColor(
                    binData[i],
                    bins.breaks,
                    mapParams.colorScale,
                    shouldSeparateZero
                ),
                value: binData[i],
            }
        }
    }
    return [joinData, maxDesirableHeight / Math.max(...binData)]
}

/**
 * Hook used to generate data for map based on data and map parameters and the
 * selected dataset. Called directly in Map pages and in the report builder
 *
 * @category Hooks
 * @param {Object} dataParams Paramters for data handling, including numerator
 *   and denominator tables, column or index accessors
 * @param {String} currentData The name of the current Geojson file loaded on
 *   the map
 * @param {Object} mapParams Map Params for map modes, bin types, etc.
 * @returns {Array} Positional array of data for map rendering, including
 *   joinData, scale, and bins. [ geography: GeoJSON.FeatureCollection
 *   colorAndValueData: {@link JoinData}, cartogramData {@link CartogramData},
 *   mapSnapshot: string (map variable hash), bins: number[],
 *   heightScaleMultiplier: number, dataReady: boolean, geojsonData:
 *   {[key:number]: GeoJSON.Feature}, currentIndex: number, isBackgroundLoading:
 *   boolean]
 */
function useMapData({ dataParams, currentData, mapParams }) {
    const { geoda, geodaReady } = useGeoda()
    const [mapSnapshot, setMapSnapshot] = useState(0)
    // Based on the data params and current geojson, useLoadData does the heavy lifting on
    // bringing us the majority of what we need. dataReady will be the trigger for much of the rest of this hook
    const {
        geojsonData,
        numeratorData,
        denominatorData,
        dateIndices,
        dataReady,
        currIndex,
        isBackgroundLoading,
    } = useLoadData({
        dataParams,
        currentData,
    })

    /**
     * CurrIndex is the reconcile index in case of null index, which defaults to
     * most recent or an index outside of the current data range when changing
     * datasets.
     */
    const combinedParams = {
        ...dataParams,
        nIndex:
            dataParams?.nType && dataParams.nType.includes('time')
                ? currIndex
                : dataParams.nIndex,
        dIndex:
            dataParams?.dType && dataParams.dType.includes('time')
                ? currIndex
                : dataParams.dIndex,
    }
    // hashed params
    const varId = getVarId(currentData, combinedParams, mapParams, dataReady)

    const binIndex = !!dateIndices
        ? mapParams.binMode === 'dynamic' &&
          dateIndices?.indexOf(combinedParams.nIndex) !== -1
            ? combinedParams.nIndex
            : dateIndices.slice(-1)[0]
        : null

    // used to generate bins
    const binData = useMemo(() => {
        return getDataForBins({
            numeratorData:
                combinedParams.numerator === 'properties'
                    ? geojsonData?.properties
                    : numeratorData?.data,
            denominatorData:
                combinedParams.denominator === 'properties'
                    ? geojsonData?.properties
                    : denominatorData?.data,
            dataParams: combinedParams,
            binIndex,
            fixedOrder:
                geojsonData?.order?.indexOrder &&
                Object.values(geojsonData.order.indexOrder),
            dataReady,
        })
    }, [
        JSON.stringify({ ...combinedParams, nIndex: 0, dIndex: 0 }),
        JSON.stringify(mapParams),
        binIndex,
        dataReady,
        currentData,
    ])

    // different than binData in that this can be a different date index
    // Meaning, bin on the most recent, then draw map on a different date
    const mapData = useMemo(
        () =>
            binIndex === combinedParams.nIndex || combinedParams.nIndex === null
                ? binData
                : getDataForBins({
                      numeratorData:
                          combinedParams.numerator === 'properties'
                              ? geojsonData?.properties
                              : numeratorData?.data,
                      denominatorData:
                          combinedParams.denominator === 'properties'
                              ? geojsonData?.properties
                              : denominatorData?.data,
                      dataParams: combinedParams,
                      binIndex: false,
                      fixedOrder:
                          geojsonData?.order?.indexOrder &&
                          Object.values(geojsonData.order.indexOrder),
                      dataReady,
                  }),
        [
            JSON.stringify(combinedParams),
            JSON.stringify(mapParams),
            dataReady,
            currentData,
        ]
    )

    const bins = useGetBins({
        currentData,
        mapParams,
        dataParams: combinedParams,
        binData,
        geoda,
        geodaReady,
        dataReady,
        // shouldSeparateZero: dataParams.separateZero // enabling this bins only non-zero values
    })

    const [lisaData, lisaVarId] = useLisaMap({
        currentData,
        dataForLisa: mapData,
        mapId: geojsonData?.mapId,
        shouldUseLisa: dataReady && mapParams.mapType === 'lisa',
        varId,
        dataReady,
    })

    const { cartogramData, cartogramCenter, cartogramDataSnapshot } =
        useCartogramMap({
            mapId: geojsonData?.mapId,
            dataForCartogram: mapData,
            shouldUseCartogram: dataReady && mapParams.vizType === 'cartogram',
            dataReady,
            varId,
            order: geojsonData?.order?.indexOrder,
            geojsonData: geojsonData?.data,
        })

    const [colorAndValueData, heightScale] = useMemo(() => {
        const data = generateJoinData({
            binData: mapData,
            bins,
            lisaData,
            cartogramData,
            mapParams,
            dataParams: combinedParams,
            order: geojsonData?.order?.indexOrder,
            dataReady,
            shouldSeparateZero: dataParams.separateZero,
        })
        !!data && setMapSnapshot(`${new Date().getTime()}`.slice(-6))
        return data
    }, [
        mapParams.binMode !== 'dynamic' &&
            mapParams.mapType === 'natural_breaks' &&
            combinedParams.nIndex,
        dataReady,
        // JSON.stringify(dataParams),
        JSON.stringify(bins),
        mapParams.mapType === 'lisa' && lisaVarId,
        currentData,
        // JSON.stringify(cartogramData),
    ])

    const sanitizedHeightScale =
        !isNaN(heightScale) && heightScale !== Infinity ? heightScale : 1

    return [
        geojsonData?.data, // geography
        colorAndValueData, // color and value data
        { cartogramData, cartogramCenter, cartogramDataSnapshot }, // cartogram data
        mapSnapshot, // string params for updater dep arrays
        bins, // bins for legend etc,
        sanitizedHeightScale, // height scale
        !(
            dataReady &&
            (bins?.breaks || lisaData) &&
            Object.keys(colorAndValueData).length
        ),
        geojsonData,
        currIndex,
        isBackgroundLoading,
    ]
}

export default useMapData