import 'maplibre-gl/dist/maplibre-gl.css' import './Chloropleth.css' import React, { useState, useMemo, useEffect } from 'react' import classnames from 'classnames' import * as tailwindColors from 'tailwindcss/colors' import ReactMapGL, { NavigationControl } from 'react-map-gl' import Measure from 'react-measure' import { interpolateMagma } from 'd3-scale-chromatic' import FadeTransition from './FadeTransition' import MapPopup from './MapPopup' import Checkbox from './Checkbox' import useQueryAsState from '../hooks/useQueryAsState' import useMediaQuery from '../hooks/useMediaQuery' // original RGBs left in for reference const colourStops = [ { index: 0, rgb: 'rgb(0, 0, 4)' }, { index: 0.13, rgb: 'rgb(28, 16, 68)' }, { index: 0.25, rgb: 'rgb(79, 18, 123)' }, { index: 0.38, rgb: 'rgb(129, 37, 129)' }, { index: 0.5, rgb: 'rgb(181, 54, 122)' }, { index: 0.63, rgb: 'rgb(229, 80, 100)' }, { index: 0.75, rgb: 'rgb(251, 135, 97)' }, { index: 0.88, rgb: 'rgb(254, 194, 135)' }, { index: 1, rgb: 'rgb(252, 253, 191)' } ].map(x => { const index = (x.index - 0.13) / (1 - 0.13) return { index, rgb: interpolateMagma(x.index) } }).slice(1) // Cut off the first bit of magma with black const makeMagmaGradient = (transform) => { const stops = [] for (let i = 0; i <= 100; i += 1) { const value = transform(i / 100) const color = interpolateMagma(value) stops.push(`${color} ${i}%`) } return `linear-gradient(to right, ${stops.join(',')})` } const RColourStops = [ { index: 0.125, rgb: 'rgb(255, 0, 0)' }, { index: 0.75, rgb: 'rgb(255, 255, 255)' }, { index: 1, rgb: 'rgb(0, 0, 255)' } ] const gradients = { linear: makeMagmaGradient(v => 1.13 - (v + 0.13) / 1.13), quadratic: makeMagmaGradient(v => 1.13 - (Math.sqrt(v) + 0.13) / 1.13), R_scale: `linear-gradient(to left, ${RColourStops.map(_ => `${_.rgb} ${_.index * 100}%`).join(',')})` } const ColourBar = ({ dmin, dmax, type, percentage, opacity }) => { let midpoint if (dmax > 2) { midpoint = Math.ceil((dmin + dmax) * 0.5) } else { midpoint = Math.round(10 * (dmin + dmax) * 0.5) / 10 } const gradient = gradients[type] const formatValue = useMemo(() => percentage ? v => { const _v = v * 100; return `${Number.isInteger(_v) ? _v : _v.toFixed(1)}%` } : (v, method = 'round') => Math[method](v).toLocaleString() , [percentage]) return ( <> <div className='h-3 rounded-sm overflow-hidden bg-white'> <div className='h-full w-full' style={{ backgroundImage: gradient, opacity }} /> </div> <div className='grid grid-cols-3 text-xs tracking-wide leading-6'> <span> {formatValue(dmin, 'floor')} </span> <span className='text-center'> {formatValue(midpoint)} </span> <span className='text-right'> {formatValue(dmax, 'ceil')} </span> </div> </> ) } function clampViewport (viewport, bounds) { if (viewport.longitude < bounds.min_longitude) { viewport.longitude = bounds.min_longitude } else if (viewport.longitude > bounds.max_longitude) { viewport.longitude = bounds.max_longitude } if (viewport.latitude < bounds.min_latitude) { viewport.latitude = bounds.min_latitude } else if (viewport.latitude > bounds.max_latitude) { viewport.latitude = bounds.max_latitude } } const mapQueryToViewport = ({ latitude = 0, longitude = 0, zoom = 0, pitch = '0', bearing = '0' }, bounds) => { const viewport = { latitude: parseFloat(latitude), longitude: parseFloat(longitude), zoom: parseFloat(zoom), pitch: parseFloat(pitch), bearing: parseFloat(bearing) } clampViewport(viewport, bounds) return viewport } const mapViewportToQuery = ({ latitude, longitude, zoom, pitch, bearing }) => ({ latitude: latitude.toFixed(6), longitude: longitude.toFixed(6), zoom: zoom.toFixed(6), pitch: pitch !== 0 ? pitch.toFixed(6) : undefined, bearing: bearing !== 0 ? bearing.toFixed(6) : undefined }) const getDependencyArray = obj => [obj.latitude, obj.longitude, obj.zoom, obj.pitch, obj.bearing] const doesNotMatch = (a, b) => ( a.latitude !== b.latitude || a.longitude !== b.longitude || a.zoom !== b.zoom || a.pitch !== b.pitch || a.bearing !== b.bearing ) const Chloropleth = (props) => { const { color_scale_type, config = {}, darkMode = false, enable_fade_uncertainty, geojson, handleOnClick, lineColor = 'blueGray', max_val, min_val, parameterConfig, selected_area, values } = props const isBig = useMediaQuery('(min-width: 2160px)') const defaultZoom = useMemo(() => { const { default_zoom, default_zoom_mob = default_zoom } = config if (typeof default_zoom === 'number') { return props.isMobile ? default_zoom_mob : default_zoom } const { desktop, mobile = desktop, big = desktop } = default_zoom if (props.isMobile) return mobile if (isBig) return big return desktop }, [config, props.isMobile, isBig]) const [query, updateQuery] = useQueryAsState({ ...mapViewportToQuery({ latitude: config.default_lat, longitude: config.default_lon, zoom: defaultZoom, pitch: 0, bearing: 0 }), uncertainty: '1' }) const [viewport, setViewport] = useState({ width: 0, height: 0, ...mapQueryToViewport(query, config.bounds) }) // for bounds checking // const vp = new WebMercatorViewport(viewport) // console.log(vp.getBounds()) useEffect(() => { setViewport({ ...viewport, ...mapQueryToViewport(query, config.bounds) }) }, getDependencyArray(query)) useEffect(() => { const timeout = setTimeout(() => { const update = mapViewportToQuery(viewport) if (doesNotMatch(update, query)) { updateQuery(update, 'replace') } }, 500) return () => clearTimeout(timeout) }, getDependencyArray(viewport)) const onViewportChange = newViewport => { clampViewport(newViewport, config.bounds) setViewport(newViewport) } const percentage = parameterConfig && parameterConfig.format === 'percentage' const hasUncertainty = useMemo(() => { if (enable_fade_uncertainty !== undefined) return enable_fade_uncertainty if (percentage) { // back compat for (const v of Object.values(values)) { if (v === undefined) return false const { upper, lower } = v if (upper !== null && lower !== null && upper !== lower) { return true } } } return false }, [enable_fade_uncertainty, values, percentage]) const features = useMemo(() => { const features = { selected: [], active: [], nulls: [], others: [] } if (values === null) { return features } for (const feature of geojson.features) { const { area_id } = feature.properties const areaValues = values[area_id] || {} if (areaValues.mean !== undefined) { features.nulls.push(feature) // white bg for alpha and smoother dark mode if (area_id === selected_area) features.selected.push(feature) const { mean, lower, upper } = areaValues if (mean !== null) { const _feature = { ...feature, properties: { ...feature.properties, value: mean, alpha: hasUncertainty ? 1 - (upper - lower) : 1 } } features.active.push(_feature) } } else { features.others.push(feature) } } return features }, [geojson, values, selected_area, hasUncertainty]) const colorScale = useMemo(() => { if (max_val === 0) { return [0, '#fff'] } const stops = color_scale_type === 'R_scale' ? RColourStops : colourStops const scale = [] const min = color_scale_type === 'quadratic' ? Math.sqrt(min_val) : min_val const max = color_scale_type === 'quadratic' ? Math.sqrt(max_val) : max_val const range = max - min for (const { index, rgb } of stops) { scale.unshift(rgb) scale.unshift(min + range * (1 - index)) } return scale }, [max_val, min_val, color_scale_type]) const showUncertainty = useMemo(() => query.uncertainty === '1', [query.uncertainty]) const mapStyle = useMemo(() => ({ version: 8, transition: { duration: 0 }, sources: { selectedAreas: { type: 'geojson', data: { ...geojson, features: features.selected } }, activeAreas: { type: 'geojson', data: { ...geojson, features: features.active } }, nullAreas: { type: 'geojson', data: { ...geojson, features: features.nulls } }, otherAreas: { type: 'geojson', data: { ...geojson, features: features.others } } }, layers: [ { id: 'other-areas-fill', type: 'fill', source: 'otherAreas', paint: { 'fill-color': darkMode ? tailwindColors[lineColor][300] : '#fff' } }, { id: 'other-areas-line', type: 'line', source: 'otherAreas', paint: { 'line-color': tailwindColors[lineColor][darkMode ? 400 : 300], 'line-width': 0.5 } }, { id: 'null-areas-fill', type: 'fill', source: 'nullAreas', paint: { 'fill-color': '#fff' } }, { id: 'null-areas-line', type: 'line', source: 'nullAreas', paint: { 'line-color': tailwindColors[lineColor][500], 'line-width': 0.5 } }, { id: 'active-areas-fill', type: 'fill', source: 'activeAreas', paint: { 'fill-color': [ 'interpolate', ['linear'], color_scale_type === 'quadratic' ? ['sqrt', ['get', 'value']] : ['get', 'value'], ...colorScale ], 'fill-opacity': showUncertainty ? ['get', 'alpha'] : 1 } }, { id: 'active-areas-line', type: 'line', source: 'activeAreas', paint: { 'line-color': tailwindColors[lineColor][600], 'line-width': 0.5 } }, { id: 'selected-areas-line', type: 'line', source: 'selectedAreas', paint: { 'line-color': tailwindColors[lineColor][900], 'line-width': 2 } } ] }), [features, colorScale, color_scale_type, showUncertainty, darkMode, lineColor]) const [hoveredFeature, setHoveredFeature] = useState(null) const hoverPopup = useMemo(() => { if (hoveredFeature === null) return null const { area_id, area_name, lat, long } = hoveredFeature.properties const value = values[area_id] if (area_id in values) { return { lat, long, value, label: area_name, onClick: () => handleOnClick(area_id) } } }, [hoveredFeature, values, handleOnClick]) const colourBarOpacity = useMemo(() => { if (hasUncertainty && showUncertainty) { return 0.666 // let sum = 0 // for (const { properties } of features.active) { // sum += properties.alpha // } // return sum / features.active.length } return 1 }, [hasUncertainty, showUncertainty, features.active]) return ( <Measure bounds onResize={rect => { setViewport({ ...viewport, width: rect.bounds.width || viewport.width, height: rect.bounds.height || viewport.height }) }} > {({ measureRef }) => ( <div ref={measureRef} className={classnames(props.className, 'relative z-0')}> <ReactMapGL {...viewport} minZoom={config.min_zoom} disableTokenWarning onViewportChange={onViewportChange} mapStyle={mapStyle} mapboxApiUrl={null} className='bg-gray-50 dark:bg-gray-600' interactiveLayerIds={['null-areas-fill', 'active-areas-fill']} onNativeClick={e => { // faster for some reason const [feature] = e.features if (!feature) { handleOnClick('overview') } else { handleOnClick(feature.properties.area_id) } }} onHover={e => { const [feature] = e.features if (feature && feature.properties.value !== 'null') { if (feature.properties.lat === undefined) { // Hack where if no central point is specified // we use mouse position for popup feature.properties.lat = e.lngLat[1] feature.properties.long = e.lngLat[0] } setHoveredFeature(feature) } else { setHoveredFeature(null) } }} getCursor={({ isHovering, isDragging }) => { if (isDragging) return 'grabbing' if (isHovering || selected_area !== 'overview') return 'pointer' return 'grab' }} // onLoad={(e) => { // // if (initialBounds) { // // e.target.fitToBounds(initialBounds) // // } // }} > <NavigationControl className='right-2 top-2 z-10' /> { hoverPopup && <MapPopup {...hoverPopup} {...parameterConfig} /> } </ReactMapGL> <FadeTransition in={max_val > 0}> <div className='absolute left-0 bottom-0 w-60 z-10 p-2 pb-0 bg-white dark:bg-gray-700 bg-opacity-80 dark:bg-opacity-80'> { hasUncertainty && <form className='mb-1.5 ml-2'> <Checkbox id='map_uncertainty' className='text-primary' checked={showUncertainty} onChange={e => updateQuery({ uncertainty: e.target.checked ? 1 : 0 }, 'replace')} > <span className='text-xs tracking-wide select-none'> fade areas by uncertainty </span> </Checkbox> </form> } <ColourBar dmin={min_val} dmax={max_val} type={color_scale_type} percentage={percentage} opacity={colourBarOpacity} /> </div> </FadeTransition> </div> )} </Measure> ) } export default Chloropleth