diff --git a/app/scripts/components/common/map/style-generators/raster-paint-layer.tsx b/app/scripts/components/common/map/style-generators/raster-paint-layer.tsx index bf5f170bf..e5aa8479d 100644 --- a/app/scripts/components/common/map/style-generators/raster-paint-layer.tsx +++ b/app/scripts/components/common/map/style-generators/raster-paint-layer.tsx @@ -13,6 +13,7 @@ interface RasterPaintLayerProps extends BaseGeneratorParams { colorMap?: string | undefined; tileParams: Record; generatorPrefix?: string; + reScale?: { min: number; max: number }; } export function RasterPaintLayer(props: RasterPaintLayerProps) { @@ -24,7 +25,8 @@ export function RasterPaintLayer(props: RasterPaintLayerProps) { hidden, opacity, colorMap, - generatorPrefix = 'raster', + reScale, + generatorPrefix = 'raster' } = props; const { updateStyle } = useMapStyle(); @@ -32,8 +34,14 @@ export function RasterPaintLayer(props: RasterPaintLayerProps) { const generatorId = `${generatorPrefix}-${id}`; const updatedTileParams = useMemo(() => { - return { ...tileParams, ...colorMap && {colormap_name: colorMap}}; - }, [tileParams, colorMap]); + return { + ...tileParams, + ...(colorMap && { + colormap_name: colorMap + }), + ...(reScale && { rescale: Object.values(reScale) }) + }; + }, [tileParams, colorMap, reScale]); // // Generate Mapbox GL layers and sources for raster timeseries @@ -47,7 +55,9 @@ export function RasterPaintLayer(props: RasterPaintLayerProps) { useEffect( () => { - const tileParamsAsString = qs.stringify(updatedTileParams, { arrayFormat: 'comma' }); + const tileParamsAsString = qs.stringify(updatedTileParams, { + arrayFormat: 'comma' + }); const zarrSource: RasterSource = { type: 'raster', @@ -63,8 +73,8 @@ export function RasterPaintLayer(props: RasterPaintLayerProps) { paint: { 'raster-opacity': hidden ? 0 : rasterOpacity, 'raster-opacity-transition': { - duration: 320, - }, + duration: 320 + } }, minzoom: minZoom, metadata: { @@ -93,7 +103,8 @@ export function RasterPaintLayer(props: RasterPaintLayerProps) { tileApiEndpoint, haveTileParamsChanged, generatorParams, - colorMap + colorMap, + reScale // generatorParams includes hidden and opacity // hidden, // opacity, diff --git a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx index 271bf74ac..d0bd548c8 100644 --- a/app/scripts/components/common/map/style-generators/raster-timeseries.tsx +++ b/app/scripts/components/common/map/style-generators/raster-timeseries.tsx @@ -63,10 +63,10 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { stacApiEndpoint, tileApiEndpoint, colorMap, + reScale } = props; const { current: mapInstance } = useMaps(); - const theme = useTheme(); const { updateStyle } = useMapStyle(); @@ -270,7 +270,9 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { controller }); const mosaicUrl = responseData.links[1].href; - setMosaicUrl(mosaicUrl.replace('/{tileMatrixSetId}', '/WebMercatorQuad')); + setMosaicUrl( + mosaicUrl.replace('/{tileMatrixSetId}', '/WebMercatorQuad') + ); } catch (error) { // @NOTE: conditional logic TO BE REMOVED once new BE endpoints have moved to prod... Fallback on old request url if new endpoints error with nonexistance... if (error.request) { @@ -284,10 +286,14 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { const mosaicUrl = responseData.links[1].href; setMosaicUrl(mosaicUrl); } else { - LOG && + LOG && /* eslint-disable-next-line no-console */ - console.log('Titiler /register %cEndpoint error', 'color: red;', error); - throw error; + console.log( + 'Titiler /register %cEndpoint error', + 'color: red;', + error + ); + throw error; } } @@ -361,7 +367,8 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { { assets: 'cog_default', ...(sourceParams ?? {}), - ...colorMap && {colormap_name: colorMap} + ...(colorMap && { colormap_name: colorMap }), + ...(reScale && {rescale: Object.values(reScale)}) }, // Temporary solution to pass different tile parameters for hls data { @@ -489,6 +496,7 @@ export function RasterTimeseries(props: RasterTimeseriesProps) { }, [ mosaicUrl, colorMap, + reScale, points, minZoom, haveSourceParamsChanged, diff --git a/app/scripts/components/common/map/types.d.ts b/app/scripts/components/common/map/types.d.ts index 899931d39..47ad68c4f 100644 --- a/app/scripts/components/common/map/types.d.ts +++ b/app/scripts/components/common/map/types.d.ts @@ -54,6 +54,7 @@ export interface BaseTimeseriesProps extends BaseGeneratorParams { zoomExtent?: number[]; onStatusChange?: (result: { status: ActionStatus; id: string }) => void; colorMap?: string; + reScale?: { min: number; max: number }; } // export interface ZarrTimeseriesProps extends BaseTimeseriesProps { diff --git a/app/scripts/components/common/uswds/index.tsx b/app/scripts/components/common/uswds/index.tsx index e5410b060..a8f151d6b 100644 --- a/app/scripts/components/common/uswds/index.tsx +++ b/app/scripts/components/common/uswds/index.tsx @@ -2,3 +2,5 @@ export { USWDSAlert } from './alert'; export { USWDSButtonGroup, USWDSButton } from './button'; export { USWDSLink } from './link'; export { USWDSBanner, USWDSBannerContent } from './banner'; + +export { USWDSTextInput, USWDSTextInputMask } from './input'; diff --git a/app/scripts/components/common/uswds/input.tsx b/app/scripts/components/common/uswds/input.tsx new file mode 100644 index 000000000..f239b6bc5 --- /dev/null +++ b/app/scripts/components/common/uswds/input.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { TextInput, TextInputMask } from "@trussworks/react-uswds"; + +export function USWDSTextInput(props) { + return ; +} + +export function USWDSTextInputMask(props) { + return ; + } \ No newline at end of file diff --git a/app/scripts/components/exploration/atoms/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts index 93ea7f5d1..115f374f3 100644 --- a/app/scripts/components/exploration/atoms/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -157,6 +157,17 @@ export function useTimelineDatasetColormap( return useAtom(colorMapAtom); } +export function useTimelineDatasetColormapScale( + datasetAtom: PrimitiveAtom +) { + const colorMapScaleAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => + optic.prop('settings').prop('scale') + ); + }, [datasetAtom]); + + return useAtom(colorMapScaleAtom); +} export const useTimelineDatasetAnalysis = ( datasetAtom: PrimitiveAtom ) => { diff --git a/app/scripts/components/exploration/components/datasets/color-range-slider.scss b/app/scripts/components/exploration/components/datasets/color-range-slider.scss new file mode 100644 index 000000000..d66d2e815 --- /dev/null +++ b/app/scripts/components/exploration/components/datasets/color-range-slider.scss @@ -0,0 +1,50 @@ +/* Removing the default appearance */ +.thumb, +.thumb::-webkit-slider-thumb { + touch-action: 'none'; + + -webkit-appearance: none; + -webkit-tap-highlight-color: transparent; +} + +.thumb { + pointer-events: none; +} +/* For Chrome browsers */ +.thumb::-webkit-slider-thumb { + -webkit-appearance: none; + pointer-events: all; + width: 20px; + height: 20px; + background-color: #fff; + border-radius: 50%; + border: 2px solid #1565ef; + border-width: 1px; + box-shadow: 0 0 0 1px #c6c6c6; + cursor: pointer; +} + +/* For Firefox browsers */ +.thumb::-moz-range-thumb { + -webkit-appearance: none; + pointer-events: all; + width: 20px; + height: 20px; + background-color: #fff; + border-radius: 50%; + border: 2px solid #1565ef; + border-width: 1px; + box-shadow: 0 0 0 1px #c6c6c6; + cursor: pointer; +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} diff --git a/app/scripts/components/exploration/components/datasets/colorRangeSlider.tsx b/app/scripts/components/exploration/components/datasets/colorRangeSlider.tsx new file mode 100644 index 000000000..835e5b42a --- /dev/null +++ b/app/scripts/components/exploration/components/datasets/colorRangeSlider.tsx @@ -0,0 +1,272 @@ +import React, { useCallback, useEffect, useState, useRef } from 'react'; + +import './color-range-slider.scss'; +import { colorMapScale } from '$components/exploration/types.d.ts'; + +import { USWDSTextInput } from '$components/common/uswds'; + +interface ColorrangeRangeSlideProps { + // Absolute minimum of color range + min: number; + + // Absolute maximum of color range + max: number; + + // Previously selected minimum and maximum of colorRangeScale + colorMapScale: colorMapScale | undefined; + + // Update colorRangeScale + setColorMapScale: (colorMapScale: colorMapScale) => void; +} + +export function ColorRangeSlider({ + min, + max, + colorMapScale, + setColorMapScale +}: ColorrangeRangeSlideProps) { + const setDefaultMin = colorMapScale?.min ? colorMapScale.min : min; + const setDefaultMax = colorMapScale?.max ? colorMapScale.max : max; + + const [minVal, setMinVal] = useState(setDefaultMin); + const [maxVal, setMaxVal] = useState(setDefaultMax); + const minValRef = useRef(setDefaultMin); + const maxValRef = useRef(setDefaultMax); + const [inputError, setInputError] = useState({ + min: false, + max: false, + largerThanMax: false, + lessThanMin: false + }); + + const range = useRef(null); + + // Convert to percentage + const getPercent = useCallback( + (value) => ((value - min) * 100) / (max - min), + [min, max] + ); + //Calculate the range + const rangeCalculation = (maxPercent, minPercent) => { + const thumbWidth = 20; + if (range.current) { + range.current.style.width = `calc(${ + maxPercent - minPercent >= 100 ? 100 : maxPercent - minPercent + }% - ${(thumbWidth - minPercent * 0.2) * (maxPercent / 100)}px )`; + } + return; + }; + const resetErrorOnSlide = (value, slider) => { + if (value > min || value < max) { + slider === 'max' + ? setInputError({ ...inputError, max: false, lessThanMin: false }) + : setInputError({ ...inputError, min: false, largerThanMax: false }); + } + }; + + const minMaxBuffer = 0.001; + const textInputClasses = + 'flex-1 radius-md height-3 font-size-3xs width-5 border-2px '; + const thumbPosition = `position-absolute pointer-events width-card height-0 outline-0`; + + useEffect(() => { + let maxValPrevious; + let minValPrevious; + //checking that there are no current errors with inputs + if (Object.values(inputError).every((error) => !error)) { + //set the filled range bar on initial load + if ( + colorMapScale && + maxVal != maxValPrevious && + minVal != minValPrevious + ) { + const minPercent = getPercent(minValRef.current); + const maxPercent = getPercent(maxValRef.current); + + rangeCalculation(maxPercent, minPercent); + + if (range.current) + range.current.style.left = `calc(${minPercent}% + ${ + 10 - minPercent * 0.2 + }px)`; + } else { + //set the filled range bar if change to max slider + if (maxVal != maxValPrevious) { + maxValPrevious = maxVal; + const minPercent = getPercent(minValRef.current); + const maxPercent = getPercent(maxVal); + rangeCalculation(maxPercent, minPercent); + } + //set the filled range bar if change to min slider + if (minVal != minValPrevious) { + minValPrevious = minVal; + const minPercent = getPercent(minVal); + const maxPercent = getPercent(maxValRef.current); + rangeCalculation(maxPercent, minPercent); + if (range.current) + range.current.style.left = `calc(${minPercent}% + ${ + 10 - minPercent * 0.2 + }px)`; + } + } + } + // determining if there is an initial colorMapeScale or if it is the default min and max + if ( + !colorMapScale || + (colorMapScale.max == max && colorMapScale.min == min) + ) { + setColorMapScale({ min: minVal, max: maxVal }); + } else + setColorMapScale({ + min: Number(minValRef.current), + max: Number(maxValRef.current) + }); /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [maxVal, minVal, getPercent, setColorMapScale, inputError, max, min]); + + return ( +
+
+ + + { + if (event.target.value == '') return (minValRef.current = minVal); + }} + onChange={(event) => { + minValRef.current = event.target.value; + const value = Number(event.target.value); + + if (value > maxVal - minMaxBuffer) + return setInputError({ ...inputError, largerThanMax: true }); + if (value < min || value > max) { + return setInputError({ ...inputError, min: true }); + } else { + setInputError({ + ...inputError, + min: false, + largerThanMax: false + }); + setMinVal(Math.min(value, maxVal - minMaxBuffer)); + } + }} + /> + +
+ { + const value = Math.min( + Number(event.target.value), + maxVal - minMaxBuffer + ); + resetErrorOnSlide(value, 'min'); + setMinVal(value); + minValRef.current = value; + }} + className={`thumb ${thumbPosition} z-index-30`} + style={{ + zIndex: minVal >= max - 10 * minMaxBuffer ? '500' : '300' + }} + /> + { + if (event.target.value == '') return (maxValRef.current = maxVal); + }} + onChange={(event) => { + const value = Math.max( + Number(event.target.value), + minVal + minMaxBuffer + ); + resetErrorOnSlide(value, 'max'); + setMaxVal(value); + maxValRef.current = value; + }} + className={`thumb ${thumbPosition} z-400`} + style={{ + zIndex: minVal <= max - 10 * minMaxBuffer ? '500' : '400' + }} + /> +
+
+
+ {/* Show divergent map middle point */} + {min < 0 ? ( +
+ ) : null} +
+
+
+ { + const value = Number(event.target.value); + maxValRef.current = event.target.value; + + if (value < minVal + minMaxBuffer) + return setInputError({ ...inputError, lessThanMin: true }); + + if (value < min || value > max) { + return setInputError({ ...inputError, max: true }); + } else { + //unsetting error + setInputError({ ...inputError, max: false, lessThanMin: false }); + setMaxVal(Math.max(value, minVal + minMaxBuffer)); + } + }} + /> + + {/* error message for min input that is outside min max of color map */} + {inputError.max || inputError.min ? ( +

+ Please enter a value between {min} and {max} +

+ ) : null}{' '} + {/* error message for min input that is larger than current max */} + {inputError.largerThanMax ? ( +

+ Please enter a value less than {maxValRef.current} +

+ ) : null} + {/* error message for min input that is less than current min */} + {inputError.lessThanMin ? ( +

+ Please enter a value larger than {minValRef.current} +

+ ) : null} +
+ ); +} diff --git a/app/scripts/components/exploration/components/datasets/colormap-options.tsx b/app/scripts/components/exploration/components/datasets/colormap-options.tsx index a6ce07e21..a57451778 100644 --- a/app/scripts/components/exploration/components/datasets/colormap-options.tsx +++ b/app/scripts/components/exploration/components/datasets/colormap-options.tsx @@ -1,23 +1,40 @@ import React, { useEffect, useState } from 'react'; -import { Icon } from "@trussworks/react-uswds"; +import { Icon } from '@trussworks/react-uswds'; import { CollecticonDrop } from '@devseed-ui/collecticons'; -import { sequentialColorMaps, divergingColorMaps, restColorMaps } from './colorMaps'; + +import { + sequentialColorMaps, + divergingColorMaps, + restColorMaps +} from './colorMaps'; import './colormap-options.scss'; +import { ColorRangeSlider } from './colorRangeSlider'; +import { colorMapScale } from '$components/exploration/types.d.ts'; export const DEFAULT_COLORMAP = 'viridis'; const CURATED_SEQUENTIAL_COLORMAPS = [ - 'viridis', 'plasma', 'inferno', 'magma', 'cividis', - 'purples', 'blues', 'reds', 'greens', 'oranges', - 'ylgnbu', 'ylgn', 'gnbu' + 'viridis', + 'plasma', + 'inferno', + 'magma', + 'cividis', + 'purples', + 'blues', + 'reds', + 'greens', + 'oranges', + 'ylgnbu', + 'ylgn', + 'gnbu' ]; -const CURATED_DIVERGING_COLORMAPS = [ - 'rdbu', 'rdylbu', 'bwr', 'coolwarm' -]; +const CURATED_DIVERGING_COLORMAPS = ['rdbu', 'rdylbu', 'bwr', 'coolwarm']; -export const classifyColormap = (colormapName: string): 'sequential' | 'diverging' | 'rest' | 'unknown' => { +export const classifyColormap = ( + colormapName: string +): 'sequential' | 'diverging' | 'rest' | 'unknown' => { const baseName = normalizeColorMap(colormapName); if (sequentialColorMaps[baseName]) { @@ -33,9 +50,16 @@ export const classifyColormap = (colormapName: string): 'sequential' | 'divergin interface ColormapOptionsProps { colorMap: string | undefined; setColorMap: (colorMap: string) => void; + min: number; + max: number; + setColorMapScale: (colorMapScale: colorMapScale) => void; + colorMapScale: colorMapScale | undefined; } -export const getColormapColors = (colormapName: string, isReversed: boolean): string[] => { +export const getColormapColors = ( + colormapName: string, + isReversed: boolean +): string[] => { const baseName = normalizeColorMap(colormapName); const colormapData = sequentialColorMaps[baseName] || @@ -49,10 +73,19 @@ export const getColormapColors = (colormapName: string, isReversed: boolean): st return `rgba(${r}, ${g}, ${b}, ${a})`; }); - return isReversed ? colors.reduceRight((acc, color) => [...acc, color], []) : colors; + return isReversed + ? colors.reduceRight((acc, color) => [...acc, color], []) + : colors; }; -export function ColormapOptions({ colorMap = DEFAULT_COLORMAP, setColorMap}: ColormapOptionsProps) { +export function ColormapOptions({ + colorMap = DEFAULT_COLORMAP, + min, + max, + setColorMap, + setColorMapScale, + colorMapScale +}: ColormapOptionsProps) { const initialIsReversed = colorMap.endsWith('_r'); const initialColorMap = normalizeColorMap(colorMap); @@ -63,9 +96,15 @@ export function ColormapOptions({ colorMap = DEFAULT_COLORMAP, setColorMap}: Col const [customColorMap, setCustomColorMap] = useState(null); useEffect(() => { - if (colormapType === 'sequential' && !CURATED_SEQUENTIAL_COLORMAPS.includes(selectedColorMap)) { + if ( + colormapType === 'sequential' && + !CURATED_SEQUENTIAL_COLORMAPS.includes(selectedColorMap) + ) { setCustomColorMap(selectedColorMap); - } else if (colormapType === 'diverging' && !CURATED_DIVERGING_COLORMAPS.includes(selectedColorMap)) { + } else if ( + colormapType === 'diverging' && + !CURATED_DIVERGING_COLORMAPS.includes(selectedColorMap) + ) { setCustomColorMap(selectedColorMap); } }, [selectedColorMap, colormapType]); @@ -74,15 +113,25 @@ export function ColormapOptions({ colorMap = DEFAULT_COLORMAP, setColorMap}: Col if (colormapType === 'sequential') { if (customColorMap) { - availableColormaps = [{ name: customColorMap }, ...CURATED_SEQUENTIAL_COLORMAPS.map(name => ({ name }))]; + availableColormaps = [ + { name: customColorMap }, + ...CURATED_SEQUENTIAL_COLORMAPS.map((name) => ({ name })) + ]; } else { - availableColormaps = CURATED_SEQUENTIAL_COLORMAPS.map(name => ({ name })); + availableColormaps = CURATED_SEQUENTIAL_COLORMAPS.map((name) => ({ + name + })); } } else if (colormapType === 'diverging') { if (customColorMap) { - availableColormaps = [{ name: customColorMap }, ...CURATED_DIVERGING_COLORMAPS.map(name => ({ name }))]; + availableColormaps = [ + { name: customColorMap }, + ...CURATED_DIVERGING_COLORMAPS.map((name) => ({ name })) + ]; } else { - availableColormaps = CURATED_DIVERGING_COLORMAPS.map(name => ({ name })); + availableColormaps = CURATED_DIVERGING_COLORMAPS.map((name) => ({ + name + })); } } else if (colormapType === 'rest') { availableColormaps = [{ name: selectedColorMap }]; @@ -105,17 +154,36 @@ export function ColormapOptions({ colorMap = DEFAULT_COLORMAP, setColorMap}: Col return (
-
Colormap options
+
+ Colormap options +
+ +
+ +
+ -
-
- {isReversed ? ( ) : ( )} - +
@@ -125,13 +193,21 @@ export function ColormapOptions({ colorMap = DEFAULT_COLORMAP, setColorMap}: Col return (
handleColorMapSelect(name.toLowerCase())} >