diff --git a/src/data/selectors/index.js b/src/data/selectors/index.js index a2c8410f..4579b05c 100644 --- a/src/data/selectors/index.js +++ b/src/data/selectors/index.js @@ -35,8 +35,6 @@ import { withIndex, recursiveMap, maxSizedMemoization, - getScaledValue, - getScaledRgbObj, orderEntireDict, addValuesToProps, recursiveBubbleMap, @@ -45,6 +43,8 @@ import { constructFetchedGeoJson, constructGeoJson, ALLOWED_RANGE_KEYS, + getScaledValueAlt, + getColorString, } from '../../utils' const workerManager = new ThreadMaxWorkers() @@ -1828,29 +1828,17 @@ export const selectSplitNodeDataFunc = createSelector( R.unnest, R.filter((d) => { const nodeType = d[1].type - const colorProp = R.path( - [nodeType, 'colorBy'], - enabledNodesFunc(mapId) - ) - const sizeProp = R.path( - [nodeType, 'sizeBy'], - enabledNodesFunc(mapId) - ) - const nullColor = R.path(['colorBy', colorProp], d[1]) - const nullSize = R.path(['sizeBy', colorProp], d[1]) - - const sizeValue = R.path(['values', sizeProp], d[1]) - const colorValue = R.path(['values', colorProp], d[1]) - - const groupable = enabledNodesFunc(mapId)[nodeType].allowGrouping - + const enabledNodes = enabledNodesFunc(mapId) + const { colorBy, sizeBy, group } = enabledNodes[nodeType] + const colorFallback = d[1].props[colorBy]?.fallback?.color + const sizeFallback = d[1].props[sizeBy]?.fallback?.size + const colorValue = d[1].values[colorBy] + const sizeValue = d[1].values[sizeBy] + // TODO: Handle `null` values in categorical props return ( - !( - R.hasPath(['colorBy', colorProp, 'nullColor'], d[1]) && - R.isNil(nullColor) - ) && - (R.isNotNil(nullSize) || R.isNotNil(sizeValue)) && - !(groupable && R.isNil(colorValue)) + (colorFallback != null || colorValue != null) && + (sizeFallback != null || sizeValue != null) && + !(group && colorValue == null) ) }), R.groupBy((d) => { @@ -1938,9 +1926,10 @@ export const selectGeoRange = createSelector( ) ) +// TODO: Explore reusing code for shared logic with `selectSplitNodeDataFunc` export const selectLineMatchingKeysByTypeFunc = createSelector( - [selectMultiLineDataFunc, selectArcRange, selectEnabledArcsFunc], - (dataFunc, arcRange, enabledArcsFunc) => + [selectEnabledArcsFunc, selectMultiLineDataFunc], + (enabledArcsFunc, dataFunc) => maxSizedMemoization( R.identity, (mapId) => @@ -1949,14 +1938,17 @@ export const selectLineMatchingKeysByTypeFunc = createSelector( R.unnest, R.map((d) => R.assoc('data_key', d[0], d[1])), R.filter((d) => { - const colorProp = R.path( - [d.type, 'colorBy'], - enabledArcsFunc(mapId) - ) - const statRange = arcRange(d.type, colorProp, mapId) - return !( - R.has('nullColor', statRange) && - R.isNil(R.prop('nullColor', statRange)) + const arcType = d.type + const enabledArcs = enabledArcsFunc(mapId) + const { colorBy, sizeBy } = enabledArcs[arcType] + const colorFallback = d.props[colorBy]?.fallback?.color + const sizeFallback = d.props[sizeBy]?.fallback?.size + const colorValue = d.values[colorBy] + const sizeValue = d.values[sizeBy] + // TODO: Handle `null` values in categorical props + return ( + (colorFallback != null || colorValue != null) && + (sizeFallback != null || sizeValue != null) ) }), R.groupBy(R.prop('type')), @@ -2215,55 +2207,72 @@ export const selectNodeClusterGeoJsonObjectFunc = createSelector( const nodeType = group.properties.type const legendObj = legendObjectsFunc(mapId)[nodeType] const effectiveNodes = effectiveNodesBy(nodeType, mapId)[0] - const sizePropObj = R.path(['properties', 'sizeProp'], group) - const sizeByProp = effectiveNodes.props[effectiveNodes.sizeBy] + const { colorBy, sizeBy } = effectiveNodes + + const sizeByProp = effectiveNodes.props[sizeBy] + const sizeObj = group.properties.sizeProp + const sizeByPropVal = sizeObj.value + const sizeFallback = R.pathOr('0', ['fallback', 'size'])(sizeByProp) const isSizeCategorical = !R.has('min')(sizeByProp) + const sizeDomain = nodeClustersFunc(mapId).range[nodeType].size const sizeRange = isSizeCategorical ? R.pluck('size')(sizeByProp.options) - : nodeClustersFunc(mapId).range[group.properties.type].size - const size = isSizeCategorical - ? parseFloat(R.propOr('0', sizePropObj.value, sizeRange)) - : getScaledValue( - R.prop('min', sizeRange), - R.prop('max', sizeRange), - parseFloat(R.prop('startSize', sizeByProp)), - parseFloat(R.prop('endSize', sizeByProp)), - parseFloat(sizePropObj.value) - ) + : [parseFloat(sizeByProp.startSize), parseFloat(sizeByProp.endSize)] + + const rawSize = + sizeByPropVal == null + ? sizeFallback + : isSizeCategorical + ? R.pathOr('0', ['options', sizeByPropVal, 'size'])(sizeByProp) + : getScaledValueAlt( + R.props(['min', 'max'])(sizeDomain), + sizeRange, + parseFloat(sizeByPropVal) + ) - const colorByProp = effectiveNodes.props[effectiveNodes.colorBy] + const colorByProp = effectiveNodes.props[colorBy] const colorObj = group.properties.colorProp - const colorDomain = nodeClustersFunc(mapId).range[nodeType].color + const colorByPropVal = R.pipe(R.when(R.isNil, R.always('')), (s) => + s.toString() + )(colorObj.value) + const colorFallback = R.pathOr('#000', ['fallback', 'color'])( + colorByProp + ) const isColorCategorical = !R.has('min')(colorByProp) - const value = R.prop('value', colorObj) + const colorDomain = nodeClustersFunc(mapId).range[nodeType].color const colorRange = isColorCategorical ? R.pluck('color')(colorByProp.options) : R.props(['startGradientColor', 'endGradientColor'])(colorByProp) - const color = isColorCategorical - ? R.propOr('', value, colorRange) - .replace(/[^\d,.]/g, '') - .split(',') - : getScaledRgbObj( - [R.prop('min', colorDomain), R.prop('max', colorDomain)], - colorRange, - value - ) + + const rawColor = + colorByPropVal === '' + ? colorFallback + : isColorCategorical + ? R.pathOr('#000', ['options', colorByPropVal, 'color'])( + colorByProp + ) + : getScaledValueAlt( + R.props(['min', 'max'])(colorDomain), + colorRange, + parseFloat(colorByPropVal) + ) + const id = R.pathOr( JSON.stringify( R.slice(0, 2, R.pathOr([], ['properties', 'grouped_ids'], group)) ), ['properties', 'id'] )(group) - const colorString = `rgba(${color.join(',')})` + return { type: 'Feature', properties: { cave_obj: group, cave_isCluster: true, cave_name: JSON.stringify([nodeType, id]), - color: colorString, - size: size / ICON_RESOLUTION, + color: getColorString(rawColor), + size: parseFloat(rawSize) / ICON_RESOLUTION, icon: legendObj.icon, }, geometry: { diff --git a/src/ui/views/map/ColorLegend.js b/src/ui/views/map/ColorLegend.js index 2ed72659..e5340e97 100644 --- a/src/ui/views/map/ColorLegend.js +++ b/src/ui/views/map/ColorLegend.js @@ -158,12 +158,7 @@ const NumericalColorLegend = ({ ) } -const CategoricalColorLegend = ({ - type, - colorBy, - colorByProps, - onChangeColor, -}) => { +const CategoricalColorLegend = ({ type, colorByProp, onChangeColor }) => { const { colorPickerProps, showColorPicker, @@ -172,34 +167,38 @@ const CategoricalColorLegend = ({ handleChange: handleChangeRaw, } = useColorPicker(onChangeColor) + const colorOptions = useMemo(() => { + const { options, fallback } = colorByProp + return R.pipe( + orderEntireDict, // Preserve order of options after state updates + // Add fallback color for null values, if available + R.when(R.always(fallback?.color != null), R.assoc('null', fallback)), + R.map(R.pick(['name', 'color'])) + )(options) + }, [colorByProp]) + const getCategoryLabel = useCallback( (option) => { const label = type === propId.SELECTOR || type === propId.TOGGLE - ? colorByProps[colorBy].options[option].name + ? colorOptions[option].name : null return label || capitalize(option) }, - [colorBy, colorByProps, type] + [colorOptions, type] ) const handleChange = useCallback( (event, value) => { - handleChangeRaw(event, value, ['options', colorPickerProps.key, 'color']) + const option = colorPickerProps.key + const path = + option === 'null' // Updating fallback color? + ? ['fallback', 'color'] + : ['options', option, 'color'] + handleChangeRaw(event, value, path) }, [handleChangeRaw, colorPickerProps.key] ) - - const colorOptions = useMemo( - () => - R.pipe( - orderEntireDict, // Preserve order of options after state updates - R.prop('options'), - R.pluck('color') - )(colorByProps[colorBy]), - [colorBy, colorByProps] - ) - return ( <> - {Object.entries(colorOptions).map(([option, value]) => ( + {Object.entries(colorOptions).map(([option, { color: value }]) => ( 'color' in value ) + const hasFallbackColor = prop.fallback?.color != null - if (hasGradientColors || hasColorOptions) { + if (hasGradientColors || hasColorOptions || hasFallbackColor) { acc[propId] = prop } return acc @@ -314,7 +314,8 @@ const ColorLegend = ({ {isCategorical ? ( ) : ( ['maps', 'data', mapId, 'legendGroups', legendGroupId, 'data', id], - [id, legendGroupId, mapId] - ) - // Valid ranges for all features const colorRange = useMemo( () => getRange(id, colorBy, mapId), @@ -118,6 +113,10 @@ export const useLegendDetails = ({ [getRange, heightBy, id, mapId] ) + const basePath = useMemo( + () => ['maps', 'data', mapId, 'legendGroups', legendGroupId, 'data', id], + [id, legendGroupId, mapId] + ) const handleSelectGroupCalc = useCallback( (pathEnd) => (value, event) => { const path = [...basePath, pathEnd] @@ -132,7 +131,17 @@ export const useLegendDetails = ({ }, [basePath, dispatch, sync] ) - + const handleToggleGroup = useMutateStateWithSync( + (event) => { + event.stopPropagation() + return { path: [...basePath, 'group'], value: !group } + }, + [basePath, group] + ) + const handleChangeShape = useMutateStateWithSync( + (event, value) => ({ path: [...basePath, shapePathEnd], value }), + [basePath, shapePathEnd] + ) const handleSelectProp = useCallback( (pathEnd, groupCalcPathEnd, groupCalcValue) => (value, event) => { const path = [...basePath, pathEnd] @@ -155,38 +164,23 @@ export const useLegendDetails = ({ [basePath, dispatch, featureTypeProps, handleSelectGroupCalc, sync] ) - const handleToggleGroup = useCallback( - (event) => { - const path = [...basePath, 'group'] - dispatch( - mutateLocal({ - path, - value: !group, - sync: !includesPath(Object.values(sync), path), - }) - ) - event.stopPropagation() - }, - [basePath, dispatch, group, sync] - ) - + const basePathProp = useMemo(() => ['mapFeatures', 'data', id, 'props'], [id]) const handleChangeColor = useCallback( (pathEnd) => (value) => { - const path = [...basePath, 'props', colorBy, ...forceArray(pathEnd)] + const path = [...basePathProp, colorBy, ...forceArray(pathEnd)] dispatch( mutateLocal({ path, - value: colorToRgba(value), + value: getColorString(value), sync: !includesPath(Object.values(sync), path), }) ) }, - [basePath, colorBy, dispatch, sync] + [basePathProp, colorBy, dispatch, sync] ) - const handleChangeSize = useCallback( (pathEnd) => (value) => { - const path = [...basePath, 'props', sizeBy, ...forceArray(pathEnd)] + const path = [...basePathProp, sizeBy, ...forceArray(pathEnd)] dispatch( mutateLocal({ path, @@ -195,12 +189,7 @@ export const useLegendDetails = ({ }) ) }, - [basePath, dispatch, sizeBy, sync] - ) - - const handleChangeShape = useMutateStateWithSync( - (event, value) => ({ path: [...basePath, shapePathEnd], value }), - [basePath, shapePathEnd] + [basePathProp, dispatch, sizeBy, sync] ) return { diff --git a/src/ui/views/map/SizeLegend.js b/src/ui/views/map/SizeLegend.js index ac50a86a..e095810a 100644 --- a/src/ui/views/map/SizeLegend.js +++ b/src/ui/views/map/SizeLegend.js @@ -188,13 +188,7 @@ const NumericalSizeLegend = ({ ) } -const CategoricalSizeLegend = ({ - type, - sizeBy, - sizeByProps, - icon, - onChangeSize, -}) => { +const CategoricalSizeLegend = ({ type, sizeByProp, icon, onChangeSize }) => { const { showSizeSlider, sizeSliderProps, @@ -204,37 +198,43 @@ const CategoricalSizeLegend = ({ handleChangeComitted: handleChangeComittedRaw, } = useSizeSlider(onChangeSize) + const sizeOptions = useMemo(() => { + const { options, fallback } = sizeByProp + return R.pipe( + orderEntireDict, // Preserve order of options after state updates + // Add fallback size for null values, if available + R.when(R.always(fallback?.size != null), R.assoc('null', fallback)), + R.map( + R.applySpec({ + name: R.prop('name'), + size: R.propOr('1px', 'size'), // In case `size` is missing + }) + ) + )(options) + }, [sizeByProp]) + const getCategoryLabel = useCallback( (option) => { const label = type === propId.SELECTOR || type === propId.TOGGLE - ? sizeByProps[sizeBy].options[option].name + ? sizeOptions[option].name : null return label || capitalize(option) }, - [sizeByProps, sizeBy, type] + [sizeOptions, type] ) const handleChangeComitted = useCallback( (event, value) => { - handleChangeComittedRaw(event, value, [ - 'options', - sizeSliderProps.key, - 'size', - ]) + const option = sizeSliderProps.key + const path = + option === 'null' // Updating fallback size? + ? ['fallback', 'size'] + : ['options', option, 'size'] + handleChangeComittedRaw(event, value, path) }, [handleChangeComittedRaw, sizeSliderProps.key] ) - - const sizeOptions = useMemo( - () => - R.pipe( - orderEntireDict, // Preserve order of options after state updates - R.prop('options'), - R.map(R.propOr('1px', 'size')) - )(sizeByProps[sizeBy]), - [sizeBy, sizeByProps] - ) return ( <> - {Object.entries(sizeOptions).map(([option, value]) => ( + {Object.entries(sizeOptions).map(([option, { size: value }]) => ( 'size' in value ) - if (hasSizeRange || hasSizeOptions) { + const hasFallbackSize = prop.fallback?.size != null + + if (hasSizeRange || hasSizeOptions || hasFallbackSize) { return { ...acc, [propId]: prop } } return acc @@ -346,7 +348,8 @@ const SizeLegend = ({ {isCategorical ? ( ) : ( { return minArray.map((min, index) => pctVal * (maxArray[index] - min) + min) } +/** + * Scales a value from a given domain to a corresponding value in the specified range. + * If the input value is outside the domain or invalid, the fallback value is returned. + * + * @param {Array} domain - The input domain for scaling (e.g., [min, max]). + * @param {Array} range - The output range corresponding to the domain (e.g., [start, end]). + * @param {number} value - The input value to scale. + * @param {any} [fallback=null] - The fallback value to return if the input is invalid or unknown. + * @returns {any} The scaled value within the range, or the fallback if the input is invalid. + */ +export const getScaledValueAlt = R.curry( + (domain, range, value, fallback = null) => { + const linearScale = scaleLinear() + .domain(domain) + .range(range) + .clamp(true) + .unknown(fallback) + return linearScale(value) // Return the scaled value or the fallback + } +) + export const generateHash = (str) => { var hash = 0 if (str.length === 0) return hash @@ -412,38 +433,13 @@ export const getChartItemColor = (name) => { const colorIndex = Math.abs(generateHash(name)) return CHART_PALETTE[colorIndex % CHART_PALETTE.length] } -/** - * Converts a d3-color RGB object into a conventional RGBA array. - * @function - * @param {Object} rgbObj - The d3-color RGB object. - * @returns {Array} A RGBA equivalent array of the given color. - * @private - */ -const rgbObjToRgbaArray = (rgbObj) => { - const opacity = R.prop('opacity', rgbObj) * 255 - return R.pipe(R.props(['r', 'g', 'b']), R.append(opacity))(rgbObj) -} // RGBA array export const rgbStrToArray = (str) => str.match(/[.\d]+/g) -export const getScaledColor = R.curry((colorDomain, colorRange, value) => - getScaledRgbObj(colorDomain, colorRange, value) -) -export const getScaledRgbObj = R.curry((colorDomain, colorRange, value) => { - const getColor = scaleLinear() - .domain(colorDomain) - .range(colorRange) - .clamp(true) - return rgbObjToRgbaArray(color(getColor(value))) -}) - -export const colorToRgba = (value) => { - const rgbColor = rgb(value) - return `rgba(${rgbColor.r}, ${rgbColor.g}, ${rgbColor.b}, ${rgbColor.opacity})` -} +export const getColorString = (rawColor) => color(rawColor).formatRgb() export const getContrastText = (bgColor) => { - const background = rgb(bgColor) + const background = color(bgColor) // luminance is calculated using the formula provided in WCAG 2.0 guidelines, // a specific weighted sum of the RGB values of the color const luminance = @@ -566,7 +562,12 @@ export const toListWithKey = (key) => export const sortedListById = R.pipe( toListWithKey('id'), - R.sortBy(R.prop('id')) + R.sortWith([ + // BUG: Doesn't work for something like row3Col1 and row2Col1 + // (a, b) => R.length(a.id) - R.length(b.id), + // If the length is the same, compare by alphabetical order + R.ascend(R.prop('id')), + ]) ) export const sortByOrderNameId = R.sortWith([ @@ -617,11 +618,9 @@ export const getQuartiles = R.ifElse( ) export const ALLOWED_RANGE_KEYS = [ + 'timeValues', 'startGradientColor', 'endGradientColor', - 'nullColor', - 'nullSize', - 'timeValues', 'startSize', 'endSize', 'startHeight', @@ -629,6 +628,7 @@ export const ALLOWED_RANGE_KEYS = [ 'min', 'max', 'options', + 'fallback', ] // checks that range is either min/max or list of strings @@ -797,7 +797,8 @@ export const constructFetchedGeoJson = ( R.pipe( R.mapObjIndexed((geoObj, geoJsonValue) => { const geoJsonProp = R.path(['geoJson', 'geoJsonProp'])(geoObj) - const geoType = R.prop('type')(geoObj) + const geoType = geoObj.type + const geoId = geoObj.data_key const filteredFeature = R.find( (feature) => R.path(['properties', geoJsonProp])(feature) === @@ -820,101 +821,106 @@ export const constructFetchedGeoJson = ( return false } else if (!filterMapFeature(filters, geoObj)) return false - const colorProp = R.path([geoObj.type, 'colorBy'], enabledItems) - const colorRange = itemRange(geoObj.type, colorProp, mapId) - const isCategorical = !R.has('min', colorRange) - const propVal = R.pipe( - R.path(['values', colorProp]), + const colorBy = enabledItems[geoObj.type].colorBy + const colorRange = itemRange(geoObj.type, colorBy, mapId) + const colorByPropVal = R.pipe( + R.path(['values', colorBy]), R.when(R.isNil, R.always('')), (s) => s.toString() )(geoObj) - - const nullColor = R.pathOr( - 'rgba(0,0,0,255)', - isCategorical ? ['options', 'nullColor', 'color'] : ['color'], + const colorFallback = R.pathOr( + '#000', + ['fallback', 'color'], colorRange ) - const color = R.equals('', propVal) - ? nullColor - : isCategorical - ? R.pathOr( - 'rgba(0,0,0,255)', - ['options', propVal, 'color'], - colorRange - ) - : `rgba(${getScaledArray( - R.prop('min', colorRange), - R.prop('max', colorRange), - R.map((val) => parseFloat(val))( - R.prop('startGradientColor', colorRange) - .replace(/[^\d,.]/g, '') - .split(',') - ), - R.map((val) => parseFloat(val))( - R.prop('endGradientColor', colorRange) - .replace(/[^\d,.]/g, '') - .split(',') - ), - parseFloat(R.path(['values', colorProp], geoObj)) - ).join(',')})` - const id = R.prop('data_key')(geoObj) - - const heightProp = R.path( - [geoObj.type, 'heightBy'], - enabledItems - ) - const heightRange = itemRange(geoObj.type, heightProp, mapId) - const heightPropVal = parseFloat( - R.path(['values', heightProp], geoObj) - ) + const isColorCategorical = !R.has('min', colorRange) + const rawColor = + colorByPropVal === '' + ? colorFallback + : isColorCategorical + ? R.pathOr('#000', ['options', colorByPropVal, 'color'])( + colorRange + ) + : getScaledValueAlt( + [colorRange.min, colorRange.max], + [ + colorRange.startGradientColor, + colorRange.endGradientColor, + ], + parseFloat(colorByPropVal) + ) + + const heightBy = enabledItems[geoObj.type].heightBy + const heightRange = itemRange(geoObj.type, heightBy, mapId) + const heightByPropVal = geoObj.values[heightBy] const defaultHeight = R.has('startHeight', geoObj) && R.has('endHeight', geoObj) ? '100' : '0' + const heightFallback = R.pathOr( + defaultHeight, + ['fallback', 'height'], + heightRange + ) - const height = isNaN(heightPropVal) - ? parseFloat(R.propOr(defaultHeight, 'nullSize', heightRange)) - : getScaledValue( - R.prop('min', heightRange), - R.prop('max', heightRange), - parseFloat(R.prop('startHeight', heightRange)), - parseFloat(R.prop('endHeight', heightRange)), - heightPropVal - ) + const isHeightCategorical = !R.has('min', heightRange) + const rawHeight = + heightByPropVal == null + ? heightFallback + : isHeightCategorical + ? R.pathOr('0', ['options', heightByPropVal, 'height'])( + heightRange + ) + : getScaledValueAlt( + [heightRange.min, heightRange.max], + [ + parseFloat(heightRange.startHeight), + parseFloat(heightRange.endHeight), + ], + parseFloat(heightByPropVal) + ) // don't calculate size, dash, or adjust path for geos if (cacheName === 'geo') return R.mergeRight(filteredFeature, { properties: { - cave_name: JSON.stringify([geoType, id]), + cave_name: JSON.stringify([geoType, geoId]), cave_obj: geoObj, - color, - height, + color: getColorString(rawColor), + height: parseFloat(rawHeight), }, }) - const sizeProp = R.path([geoObj.type, 'sizeBy'], enabledItems) - const sizeRange = itemRange(geoObj.type, sizeProp, mapId) - const sizePropVal = parseFloat( - R.path(['values', sizeProp], geoObj) + const sizeBy = enabledItems[geoObj.type].sizeBy + const sizeRange = itemRange(geoObj.type, sizeBy, mapId) + const sizeByPropVal = geoObj.values[sizeBy] + const sizeFallback = R.pathOr( + '0', + ['fallback', 'size'], + sizeRange ) - const size = isNaN(sizePropVal) - ? parseFloat(R.propOr('0', 'nullSize', sizeRange)) - : getScaledValue( - R.prop('min', sizeRange), - R.prop('max', sizeRange), - parseFloat(R.prop('startSize', sizeRange)), - parseFloat(R.prop('endSize', sizeRange)), - sizePropVal - ) - - const dashPattern = R.propOr( - 'solid', - 'lineBy' - )(R.path([geoType, 'colorBy'], enabledItems)) - if (size === 0 || parseFloat(R.last(R.split(',', color))) < 1) + const isSizeCategorical = !R.has('min', sizeRange) + const rawSize = + sizeByPropVal == null + ? sizeFallback + : isSizeCategorical + ? R.pathOr('0', ['options', sizeByPropVal, 'size'])( + sizeRange + ) + : getScaledValueAlt( + [sizeRange.min, sizeRange.max], + [ + parseFloat(sizeRange.startSize), + parseFloat(sizeRange.endSize), + ], + parseFloat(sizeByPropVal) + ) + + const dashPattern = enabledItems[geoType].lineBy ?? 'solid' + + if (parseFloat(rawSize) === 0 || color(rawColor).opacity === 0) return false const adjustedFeature = R.assocPath( @@ -923,14 +929,15 @@ export const constructFetchedGeoJson = ( R.pathOr([], ['geometry', 'coordinates'])(filteredFeature) ) )(filteredFeature) + return R.mergeRight(adjustedFeature, { properties: { - cave_name: JSON.stringify([geoType, id]), + cave_name: JSON.stringify([geoType, geoId]), cave_obj: geoObj, - color, dash: dashPattern, - size, - height, + color: getColorString(rawColor), + size: parseFloat(rawSize), + height: parseFloat(rawHeight), }, }) }), @@ -966,96 +973,93 @@ export const constructGeoJson = ( )(legendObj) if (!filterMapFeature(filters, item)) return false - const colorProp = legendObj.colorBy - const colorPropVal = R.pipe( - R.path(['values', colorProp]), + const { colorBy } = legendObj + const colorByPropVal = R.pipe( + R.path(['values', colorBy]), R.when(R.isNil, R.always('')), (s) => s.toString() )(item) - const colorRange = itemRange(item.type, colorProp, mapId) - const isColorCategorical = !R.has('min', colorRange) - - const nullColor = R.pathOr( - 'rgba(0,0,0,255)', - isColorCategorical ? ['options', 'nullColor', 'color'] : ['color'], + const colorRange = itemRange(item.type, colorBy, mapId) + const colorFallback = R.pathOr( + '#000', + ['fallback', 'color'], colorRange ) - const color = isColorCategorical - ? R.map((val) => parseFloat(val))( - R.pathOr( - 'rgba(0,0,0,255)', - ['options', colorPropVal, 'color'], - colorRange - ) - .replace(/[^\d,.]/g, '') - .split(',') - ) - : getScaledArray( - R.prop('min', colorRange), - R.prop('max', colorRange), - R.map((val) => parseFloat(val))( - R.prop('startGradientColor', colorRange) - .replace(/[^\d,.]/g, '') - .split(',') - ), - R.map((val) => parseFloat(val))( - R.prop('endGradientColor', colorRange) - .replace(/[^\d,.]/g, '') - .split(',') - ), - parseFloat(colorPropVal) - ) - const colorString = R.equals('', colorPropVal) - ? nullColor - : `rgba(${color.join(',')})` - - let size = null + const isColorCategorical = !R.has('min', colorRange) + const rawColor = + colorByPropVal === '' + ? colorFallback + : isColorCategorical + ? R.pathOr('#000', ['options', colorByPropVal, 'color'])( + colorRange + ) + : getScaledValueAlt( + [colorRange.min, colorRange.max], + [ + colorRange.startGradientColor, + colorRange.endGradientColor, + ], + parseFloat(colorByPropVal) + ) + + let rawSize if (type === 'node' || type === 'arc') { - const sizeProp = legendObj.sizeBy - const sizeRange = itemRange(item.type, sizeProp, mapId) - const sizePropVal = R.path(['values', sizeProp], item) + const { sizeBy } = legendObj + const sizeRange = itemRange(item.type, sizeBy, mapId) + const sizeByPropVal = item.values[sizeBy] + const sizeFallback = R.pathOr('0', ['fallback', 'size'], sizeRange) + const isSizeCategorical = !R.has('min', sizeRange) - size = R.isNil(sizePropVal) - ? parseFloat(R.propOr('0', 'nullSize', sizeRange)) - : isSizeCategorical - ? parseFloat(R.propOr('0', sizePropVal, sizeRange)) - : getScaledValue( - R.prop('min', sizeRange), - R.prop('max', sizeRange), - parseFloat(R.prop('startSize', sizeRange)), - parseFloat(R.prop('endSize', sizeRange)), - parseFloat(sizePropVal) - ) + rawSize = + sizeByPropVal == null + ? sizeFallback + : isSizeCategorical + ? R.pathOr('0', ['options', sizeByPropVal, 'size'])(sizeRange) + : getScaledValueAlt( + [sizeRange.min, sizeRange.max], + [ + parseFloat(sizeRange.startSize), + parseFloat(sizeRange.endSize), + ], + parseFloat(sizeByPropVal) + ) } + if (rawSize === 0 || color(rawColor).opacity === 0) return false - if (size === 0 || parseFloat(R.last(R.split(',', colorString))) < 1) - return false - - let height = null + let rawHeight if (type === 'geo' || type === 'arc') { - const heightProp = legendObj.heightBy - const heightRange = itemRange(item.type, heightProp, mapId) - - const heightPropVal = parseFloat( - R.path(['values', heightProp], item) - ) + const { heightBy } = legendObj + const heightRange = itemRange(item.type, heightBy, mapId) + const heightByPropVal = item.values[heightBy] const defaultHeight = R.has('startHeight', item) && R.has('endHeight', item) ? '100' : '0' + const heightFallback = R.pathOr( + defaultHeight, + ['fallback', 'height'], + heightRange + ) - height = isNaN(heightPropVal) - ? parseFloat(R.propOr(defaultHeight, 'nullSize', heightRange)) - : getScaledValue( - R.prop('min', heightRange), - R.prop('max', heightRange), - parseFloat(R.prop('startHeight', heightRange)), - parseFloat(R.prop('endHeight', heightRange)), - heightPropVal - ) + const isHeightCategorical = !R.has('min', heightRange) + rawHeight = + heightByPropVal == null + ? heightFallback + : isHeightCategorical + ? R.pathOr('0', ['options', heightByPropVal, 'height'])( + heightRange + ) + : getScaledValueAlt( + [heightRange.min, heightRange.max], + [ + parseFloat(heightRange.startHeight), + parseFloat(heightRange.endHeight), + ], + parseFloat(heightByPropVal) + ) } return { @@ -1063,10 +1067,13 @@ export const constructGeoJson = ( properties: { cave_obj: item, cave_name: JSON.stringify([item.type, id]), - color: colorString, - ...(R.isNotNil(height) && { height }), - ...(R.isNotNil(size) && { - size: type === 'node' ? size / ICON_RESOLUTION : size, + color: getColorString(rawColor), + ...(R.isNotNil(rawHeight) && { height: parseFloat(rawHeight) }), + ...(R.isNotNil(rawSize) && { + size: + type === 'node' + ? parseFloat(rawSize) / ICON_RESOLUTION + : parseFloat(rawSize), }), ...(type === 'node' && { icon: legendObj.icon }), ...(type === 'arc' && {