diff --git a/packages/base/src/dialogs/symbology/classificationModes.ts b/packages/base/src/dialogs/symbology/classificationModes.ts index 6229fc367..97b40a0a9 100644 --- a/packages/base/src/dialogs/symbology/classificationModes.ts +++ b/packages/base/src/dialogs/symbology/classificationModes.ts @@ -47,26 +47,13 @@ export namespace VectorClassifications { }; export const calculateEqualIntervalBreaks = ( - values: number[], nClasses: number, - ) => { - const minimum = Math.min(...values); - const maximum = Math.max(...values); - - const breaks: number[] = []; - const step = (maximum - minimum) / nClasses; - - let value = minimum; - - for (let i = 0; i < nClasses; i++) { - value += step; - breaks.push(value); - } - - breaks[nClasses - 1] = maximum; - - return breaks; - }; + minimum: number, + maximum: number, + ): number[] => + Array.from({ length: nClasses }, (_, i) => { + return minimum + (i / (nClasses - 1)) * (maximum - minimum); + }); export const calculateJenksBreaks = (values: number[], nClasses: number) => { const maximum = Math.max(...values); diff --git a/packages/base/src/dialogs/symbology/colorRampUtils.ts b/packages/base/src/dialogs/symbology/colorRampUtils.ts index 43e381ae3..c3e24ad95 100644 --- a/packages/base/src/dialogs/symbology/colorRampUtils.ts +++ b/packages/base/src/dialogs/symbology/colorRampUtils.ts @@ -2,96 +2,27 @@ import colormap from 'colormap'; import colorScale from 'colormap/colorScale.js'; import { useEffect } from 'react'; +import { COLOR_RAMP_DEFINITIONS } from '@/src/dialogs/symbology/colorRamps'; import rawCmocean from '@/src/dialogs/symbology/components/color_ramp/cmocean.json'; - -export interface IColorMap { - name: ColorRampName; - colors: string[]; -} +import { objectEntries } from '@/src/tools'; +import { IColorMap } from '@/src/types'; const { __license__: _, ...cmocean } = rawCmocean; Object.assign(colorScale, cmocean); -export const COLOR_RAMP_NAMES = [ - 'jet', - 'hsv', - 'hot', - 'cool', - 'spring', - 'summer', - 'autumn', - 'winter', - 'bone', - 'copper', - 'greys', - 'YiGnBu', - 'greens', - 'YiOrRd', - 'bluered', - 'RdBu', - 'picnic', - 'rainbow', - 'portland', - 'blackbody', - 'earth', - 'electric', - 'viridis', - 'inferno', - 'magma', - 'plasma', - 'warm', - 'rainbow-soft', - 'bathymetry', - 'cdom', - 'chlorophyll', - 'density', - 'freesurface-blue', - 'freesurface-red', - 'oxygen', - 'par', - 'phase', - 'salinity', - 'temperature', - 'turbidity', - 'velocity-blue', - 'velocity-green', - 'cubehelix', - 'ice', - 'oxy', - 'matter', - 'amp', - 'tempo', - 'rain', - 'topo', - 'balance', - 'delta', - 'curl', - 'diff', - 'tarn', -] as const; - -export const COLOR_RAMP_DEFAULTS: Partial> = { - hsv: 11, - picnic: 11, - 'rainbow-soft': 11, - cubehelix: 16, -} as const; - -export type ColorRampName = (typeof COLOR_RAMP_NAMES)[number]; - export const getColorMapList = (): IColorMap[] => { const colorMapList: IColorMap[] = []; - COLOR_RAMP_NAMES.forEach(name => { - const colorRamp = colormap({ + for (const [name, definition] of objectEntries(COLOR_RAMP_DEFINITIONS)) { + const colors = colormap({ colormap: name, nshades: 255, format: 'rgbaString', }); - colorMapList.push({ name, colors: colorRamp }); - }); + colorMapList.push({ name, colors, definition }); + } return colorMapList; }; @@ -99,7 +30,9 @@ export const getColorMapList = (): IColorMap[] => { /** * Hook that loads and sets color maps. */ -export const useColorMapList = (setColorMaps: (maps: IColorMap[]) => void) => { +export const useColorMapList = ( + setColorMaps: React.Dispatch>, +) => { useEffect(() => { setColorMaps(getColorMapList()); }, [setColorMaps]); diff --git a/packages/base/src/dialogs/symbology/colorRamps.ts b/packages/base/src/dialogs/symbology/colorRamps.ts new file mode 100644 index 000000000..0f274c93d --- /dev/null +++ b/packages/base/src/dialogs/symbology/colorRamps.ts @@ -0,0 +1,59 @@ +import { IColorRampDefinition } from '@/src/types'; + +export const COLOR_RAMP_DEFINITIONS = { + 'rainbow-soft': { type: 'Cyclic' }, + hsv: { type: 'Cyclic' }, + phase: { type: 'Cyclic' }, + jet: { type: 'Sequential' }, + hot: { type: 'Sequential' }, + cool: { type: 'Sequential' }, + spring: { type: 'Sequential' }, + summer: { type: 'Sequential' }, + autumn: { type: 'Sequential' }, + winter: { type: 'Sequential' }, + bone: { type: 'Sequential' }, + copper: { type: 'Sequential' }, + greys: { type: 'Sequential' }, + YiGnBu: { type: 'Sequential' }, + greens: { type: 'Sequential' }, + YiOrRd: { type: 'Sequential' }, + bluered: { type: 'Sequential' }, + RdBu: { type: 'Sequential' }, + rainbow: { type: 'Sequential' }, + portland: { type: 'Sequential' }, + blackbody: { type: 'Sequential' }, + earth: { type: 'Sequential' }, + electric: { type: 'Sequential' }, + viridis: { type: 'Sequential' }, + inferno: { type: 'Sequential' }, + magma: { type: 'Sequential' }, + plasma: { type: 'Sequential' }, + warm: { type: 'Sequential' }, + bathymetry: { type: 'Sequential' }, + cdom: { type: 'Sequential' }, + chlorophyll: { type: 'Sequential' }, + density: { type: 'Sequential' }, + 'freesurface-blue': { type: 'Sequential' }, + 'freesurface-red': { type: 'Sequential' }, + oxygen: { type: 'Sequential' }, + par: { type: 'Sequential' }, + salinity: { type: 'Sequential' }, + temperature: { type: 'Sequential' }, + turbidity: { type: 'Sequential' }, + 'velocity-blue': { type: 'Sequential' }, + 'velocity-green': { type: 'Sequential' }, + cubehelix: { type: 'Sequential' }, + ice: { type: 'Sequential' }, + oxy: { type: 'Sequential' }, + matter: { type: 'Sequential' }, + amp: { type: 'Sequential' }, + tempo: { type: 'Sequential' }, + rain: { type: 'Sequential' }, + topo: { type: 'Sequential' }, + picnic: { type: 'Divergent', criticalValue: 0.5 }, + balance: { type: 'Divergent', criticalValue: 0.5 }, + delta: { type: 'Divergent', criticalValue: 0.5 }, + curl: { type: 'Divergent', criticalValue: 0.5 }, + diff: { type: 'Divergent', criticalValue: 0.5 }, + tarn: { type: 'Divergent', criticalValue: 0.5 }, +} as const satisfies { [key: string]: IColorRampDefinition }; diff --git a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampControls.tsx b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampControls.tsx index 0d2d7050b..e7d5a46d5 100644 --- a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampControls.tsx +++ b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampControls.tsx @@ -21,10 +21,12 @@ import { IDict } from '@jupytergis/schema'; import { Button } from '@jupyterlab/ui-components'; import React, { useEffect, useState } from 'react'; +import { COLOR_RAMP_DEFINITIONS } from '@/src/dialogs/symbology/colorRamps'; import { LoadingIcon } from '@/src/shared/components/loading'; +import { COLOR_RAMP_DEFAULTS, ColorRampName } from '@/src/types'; import ColorRampSelector from './ColorRampSelector'; +import { ColorRampValueControls } from './ColorRampValueControls'; import ModeSelectRow from './ModeSelectRow'; -import { COLOR_RAMP_DEFAULTS, ColorRampName } from '../../colorRampUtils'; interface IColorRampControlsProps { modeOptions: string[]; @@ -32,17 +34,32 @@ interface IColorRampControlsProps { classifyFunc: ( selectedMode: string, numberOfShades: string, - selectedRamp: string, + selectedRamp: ColorRampName, + reverseRamp: boolean, setIsLoading: (isLoading: boolean) => void, + minValue: number, + maxValue: number, + criticalValue?: number, ) => void; showModeRow: boolean; showRampSelector: boolean; + renderType: + | 'Graduated' + | 'Categorized' + | 'Heatmap' + | 'Singleband Pseudocolor'; + dataMin?: number; + dataMax?: number; } export type ColorRampControlsOptions = { selectedRamp: string; numberOfShades: string; selectedMode: string; + minValue: number; + maxValue: number; + criticalValue?: number; + reverseRamp: boolean; }; const ColorRampControls: React.FC = ({ @@ -51,19 +68,30 @@ const ColorRampControls: React.FC = ({ classifyFunc, showModeRow, showRampSelector, + renderType, + dataMin, + dataMax, }) => { - const [selectedRamp, setSelectedRamp] = useState(''); + const [selectedRamp, setSelectedRamp] = useState('viridis'); + const [reverseRamp, setReverseRamp] = useState(false); const [selectedMode, setSelectedMode] = useState(''); const [numberOfShades, setNumberOfShades] = useState(''); + const [minValue, setMinValue] = useState(dataMin); + const [maxValue, setMaxValue] = useState(dataMax); const [isLoading, setIsLoading] = useState(false); const [warning, setWarning] = useState(null); useEffect(() => { - if (selectedRamp === '' && selectedMode === '' && numberOfShades === '') { - populateOptions(); + if (selectedMode === '' && numberOfShades === '') { + initializeState(); } }, [layerParams]); + useEffect(() => { + setMinValue(layerParams.symbologyState?.min ?? dataMin); + setMaxValue(layerParams.symbologyState?.max ?? dataMax); + }, [dataMin, dataMax]); + useEffect(() => { if (!selectedRamp) { return; @@ -96,23 +124,68 @@ const ColorRampControls: React.FC = ({ setWarning(null); } }, [selectedRamp, numberOfShades]); - const populateOptions = () => { - let nClasses, singleBandMode, colorRamp; + const initializeState = () => { + let nClasses, singleBandMode, colorRamp, reverseRamp; if (layerParams.symbologyState) { nClasses = layerParams.symbologyState.nClasses; singleBandMode = layerParams.symbologyState.mode; colorRamp = layerParams.symbologyState.colorRamp; + reverseRamp = layerParams.symbologyState.reverse; } - const defaultRamp = colorRamp ? colorRamp : 'viridis'; + const defaultRamp = colorRamp ?? 'viridis'; const defaultClasses = nClasses ?? COLOR_RAMP_DEFAULTS[defaultRamp as ColorRampName] ?? 9; setNumberOfShades(defaultClasses.toString()); - setSelectedMode(singleBandMode ? singleBandMode : 'equal interval'); + setSelectedMode(singleBandMode ?? 'equal interval'); setSelectedRamp(defaultRamp); + setReverseRamp(reverseRamp ?? false); }; + const rampDef = COLOR_RAMP_DEFINITIONS[selectedRamp]; + + if (rampDef === undefined) { + // Typeguard: This should never happen + return; + } + const scaledCritical = + rampDef.type === 'Divergent' && + minValue !== undefined && + maxValue !== undefined + ? minValue + rampDef.criticalValue * (maxValue - minValue) + : undefined; + + useEffect(() => { + if (renderType === 'Heatmap') { + return; + } + + if (!layerParams.symbologyState) { + layerParams.symbologyState = {}; + } + layerParams.symbologyState = { + ...layerParams.symbologyState, + dataMin, + dataMax, + min: minValue, + max: maxValue, + colorRamp: selectedRamp, + reverse: reverseRamp, + nClasses: numberOfShades, + mode: selectedMode, + }; + }, [ + minValue, + maxValue, + selectedRamp, + reverseRamp, + selectedMode, + numberOfShades, + dataMin, + dataMax, + ]); + return (
{showRampSelector && ( @@ -121,9 +194,24 @@ const ColorRampControls: React.FC = ({
)} + + + {showModeRow && ( = ({ ) : ( diff --git a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelector.tsx b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelector.tsx index 3cb958cdb..7e5a5eb1e 100644 --- a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelector.tsx +++ b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelector.tsx @@ -15,21 +15,21 @@ import { Button } from '@jupyterlab/ui-components'; import React, { useEffect, useRef, useState } from 'react'; import { useColorMapList } from '@/src/dialogs/symbology/colorRampUtils'; +import { IColorMap } from '@/src/types'; import ColorRampSelectorEntry from './ColorRampSelectorEntry'; -export interface IColorMap { - name: string; - colors: string[]; -} - interface IColorRampSelectorProps { selectedRamp: string; setSelected: (item: any) => void; + reverse: boolean; + setReverse: React.Dispatch>; } const ColorRampSelector: React.FC = ({ selectedRamp, setSelected, + reverse, + setReverse, }) => { const containerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -43,7 +43,7 @@ const ColorRampSelector: React.FC = ({ if (colorMaps.length > 0) { updateCanvas(selectedRamp); } - }, [selectedRamp]); + }, [selectedRamp, colorMaps, reverse]); const toggleDropdown = () => { setIsOpen(!isOpen); @@ -85,7 +85,7 @@ const ColorRampSelector: React.FC = ({ for (let i = 0; i <= 255; i++) { ctx.beginPath(); - const color = ramp[0].colors[i]; + const color = reverse ? ramp[0].colors[255 - i] : ramp[0].colors[i]; ctx.fillStyle = color; ctx.fillRect(i * 2, 0, 2, canvasHeight); @@ -128,6 +128,17 @@ const ColorRampSelector: React.FC = ({ /> ))} + +
+ +
); }; diff --git a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelectorEntry.tsx b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelectorEntry.tsx index 4bcaf4615..fbd2180d2 100644 --- a/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelectorEntry.tsx +++ b/packages/base/src/dialogs/symbology/components/color_ramp/ColorRampSelectorEntry.tsx @@ -12,7 +12,7 @@ import React, { useEffect } from 'react'; -import { IColorMap } from './ColorRampSelector'; +import { IColorMap } from '@/src/types'; interface IColorRampSelectorEntryProps { index: number; @@ -58,7 +58,9 @@ const ColorRampSelectorEntry: React.FC = ({ onClick={() => onClick(colorMap.name)} className="jp-gis-color-ramp-entry" > - {colorMap.name} + + {colorMap.name} ({colorMap.definition.type}) + >; + selectedMax: number | undefined; + setSelectedMax: React.Dispatch>; + rampDef: IColorRampDefinition; + renderType: + | 'Categorized' + | 'Graduated' + | 'Heatmap' + | 'Singleband Pseudocolor'; + dataMin?: number; + dataMax?: number; + selectedMode: string; // TODO: should be ClssificationMode (https://github.com/geojupyter/jupytergis/pull/937) +} +export const ColorRampValueControls: React.FC< + IColorRampValueControlsProps +> = props => { + const permittedRenderTypes = ['Graduated', 'Singleband Pseudocolor']; + if (!permittedRenderTypes.includes(props.renderType)) { + return; + } + + const modesSupportingMinMax = ['equal interval', 'continuous']; + const enableMinMax = modesSupportingMinMax.includes(props.selectedMode); + + const formatMode = (mode: string) => + mode.charAt(0).toUpperCase() + mode.slice(1); + + return ( + <> + {props.rampDef.type === 'Divergent' && + props.selectedMode === 'equal interval' && + props.selectedMin !== undefined && + props.selectedMax !== undefined && ( +
+ + + {`${( + props.selectedMin + + props.rampDef.criticalValue * + (props.selectedMax - props.selectedMin) + ).toFixed( + 2, + )} (Colormap diverges at ${props.rampDef.criticalValue * 100}%)`} + +
+ )} + +
+ + + props.setSelectedMin( + e.target.value !== '' ? parseFloat(e.target.value) : undefined, + ) + } + className={'jp-mod-styled'} + placeholder="Enter min value" + disabled={!enableMinMax} + /> +
+ +
+ + + props.setSelectedMax( + e.target.value !== '' ? parseFloat(e.target.value) : undefined, + ) + } + className={'jp-mod-styled'} + placeholder="Enter max value" + disabled={!enableMinMax} + /> +
+ +
+ {!enableMinMax ? ( +
+ ⚠️ Warning: User-specified min/max values are not supported for " + {formatMode(props.selectedMode)}" mode. +
+ ) : ( +
+ )} + + +
+ + ); +}; diff --git a/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts b/packages/base/src/dialogs/symbology/hooks/useGetMultiBandInfo.ts similarity index 77% rename from packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts rename to packages/base/src/dialogs/symbology/hooks/useGetMultiBandInfo.ts index fd1074eb3..2c3e18aaf 100644 --- a/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts +++ b/packages/base/src/dialogs/symbology/hooks/useGetMultiBandInfo.ts @@ -4,23 +4,12 @@ import { useEffect, useState } from 'react'; import { loadFile } from '@/src/tools'; -export interface IBandHistogram { - buckets: number[]; - count: number; - max: number; - min: number; -} - export interface IBandRow { band: number; colorInterpretation?: string; - stats: { - minimum: number; - maximum: number; - }; } -const useGetBandInfo = (model: IJupyterGISModel, layer: IJGISLayer) => { +const useGetMultiBandInfo = (model: IJupyterGISModel, layer: IJGISLayer) => { const [bandRows, setBandRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -30,7 +19,6 @@ const useGetBandInfo = (model: IJupyterGISModel, layer: IJGISLayer) => { setError(null); try { - const bandsArr: IBandRow[] = []; const source = model.getSource(layer?.parameters?.source); const sourceInfo = source?.parameters?.urls[0]; @@ -54,30 +42,23 @@ const useGetBandInfo = (model: IJupyterGISModel, layer: IJGISLayer) => { type: 'GeoTiffSource', model, }); - if (!preloadedFile.file) { setError('Failed to load local file.'); setLoading(false); return; } - tiff = await fromBlob(preloadedFile.file); } const image = await tiff.getImage(); const numberOfBands = image.getSamplesPerPixel(); + const rows: IBandRow[] = []; for (let i = 0; i < numberOfBands; i++) { - bandsArr.push({ - band: i, - stats: { - minimum: sourceInfo.min ?? 0, - maximum: sourceInfo.max ?? 100, - }, - }); + rows.push({ band: i }); } - setBandRows(bandsArr); + setBandRows(rows); } catch (err: any) { setError(`Error fetching band info: ${err.message}`); } finally { @@ -92,4 +73,4 @@ const useGetBandInfo = (model: IJupyterGISModel, layer: IJGISLayer) => { return { bandRows, setBandRows, loading, error }; }; -export default useGetBandInfo; +export default useGetMultiBandInfo; diff --git a/packages/base/src/dialogs/symbology/hooks/useGetSingleBandInfo.ts b/packages/base/src/dialogs/symbology/hooks/useGetSingleBandInfo.ts new file mode 100644 index 000000000..3445650f8 --- /dev/null +++ b/packages/base/src/dialogs/symbology/hooks/useGetSingleBandInfo.ts @@ -0,0 +1,142 @@ +import { IJGISLayer, IJupyterGISModel } from '@jupytergis/schema'; +import { fromUrl, fromBlob } from 'geotiff'; +import { useEffect, useState } from 'react'; + +import { loadFile } from '@/src/tools'; + +export interface IBandRow { + band: number; + colorInterpretation?: string; + stats: { + minimum: number; + maximum: number; + }; +} + +const useGetSingleBandInfo = ( + model: IJupyterGISModel, + layer: IJGISLayer, + layerId: string, + selectedBand: number, +) => { + const [bandRows, setBandRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchBandInfo = async () => { + setLoading(true); + setError(null); + + try { + const bandsArr: IBandRow[] = []; + const source = model.getSource(layer?.parameters?.source); + const sourceInfo = source?.parameters?.urls[0]; + + if (!sourceInfo?.url) { + setError('No source URL found.'); + setLoading(false); + return; + } + + let tiff; + if ( + sourceInfo.url.startsWith('http') || + sourceInfo.url.startsWith('https') + ) { + // Handle remote GeoTIFF file + tiff = await fromUrl(sourceInfo.url); + } else { + // Handle local GeoTIFF file + const preloadedFile = await loadFile({ + filepath: sourceInfo.url, + type: 'GeoTiffSource', + model, + }); + + if (!preloadedFile.file) { + setError('Failed to load local file.'); + setLoading(false); + return; + } + + tiff = await fromBlob(preloadedFile.file); + } + + const image = await tiff.getImage(); + const numberOfBands = image.getSamplesPerPixel(); + + // 1. Try metadata first + let dataMin = image.fileDirectory.STATISTICS_MINIMUM; + let dataMax = image.fileDirectory.STATISTICS_MAXIMUM; + + if (dataMin === undefined || dataMax === undefined) { + // 2. Try smallest overview if available + const overviewCount = await tiff.getImageCount(); + const targetImage = + overviewCount > 1 ? await tiff.getImage(overviewCount - 1) : image; + + // 3. Read downsampled raster (fast) + const rasters = await targetImage.readRasters(); + dataMin = Infinity; + dataMax = -Infinity; + + const bandIndex = selectedBand - 1; + const bandData = rasters[bandIndex] as + | Float32Array + | Uint16Array + | Int16Array; + if (bandData) { + for (let j = 0; j < bandData.length; j++) { + const val = bandData[j]; + if (val < dataMin) { + dataMin = val; + } + if (val > dataMax) { + dataMax = val; + } + } + } + } + + model.sharedModel.updateLayer(layerId, { + ...layer, + parameters: { + ...layer.parameters, + symbologyState: { + ...(layer.parameters?.symbologyState ?? {}), + dataMin, + dataMax, + band: selectedBand, + }, + }, + }); + + console.debug(`[Symbology Init] Final Min=${dataMin}, Max=${dataMax}`); + + for (let i = 0; i < numberOfBands; i++) { + bandsArr.push({ + band: i + 1, + stats: { + minimum: dataMin ?? 0, + maximum: dataMax ?? 100, + }, + }); + } + + setBandRows(bandsArr); + } catch (err: any) { + console.error(err); + setError(`Error fetching band info: ${err.message}`); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchBandInfo(); + }, [selectedBand]); + + return { bandRows, loading, error }; +}; + +export default useGetSingleBandInfo; diff --git a/packages/base/src/dialogs/symbology/symbologyUtils.ts b/packages/base/src/dialogs/symbology/symbologyUtils.ts index afc3216ae..1b06a640e 100644 --- a/packages/base/src/dialogs/symbology/symbologyUtils.ts +++ b/packages/base/src/dialogs/symbology/symbologyUtils.ts @@ -1,6 +1,8 @@ import { IJGISLayer } from '@jupytergis/schema'; import colormap from 'colormap'; +import { ColorRampName } from '@/src/types'; +import { VectorClassifications } from './classificationModes'; import { IStopRow } from './symbologyDialog'; const COLOR_EXPR_STOPS_START = 3; @@ -101,10 +103,29 @@ export namespace VectorUtils { export namespace Utils { export const getValueColorPairs = ( stops: number[], - selectedRamp: string, + selectedRamp: ColorRampName, nClasses: number, reverse = false, + renderType: + | 'Categorized' + | 'Graduated' + | 'Heatmap' + | 'Singleband Pseudocolor', + minValue: number, + maxValue: number, ) => { + let effectiveStops: number[] = []; + + if (stops && stops.length > 0) { + effectiveStops = stops.map(v => parseFloat(v.toFixed(2))); + } else { + effectiveStops = VectorClassifications.calculateEqualIntervalBreaks( + nClasses, + minValue, + maxValue, + ).map(v => parseFloat(v.toFixed(2))); + } + let colorMap = colormap({ colormap: selectedRamp, nshades: nClasses > 9 ? nClasses : 9, @@ -127,7 +148,7 @@ export namespace Utils { // Get the last n/2 elements from the second array const secondPart = colorMap.slice( - colorMap.length - (stops.length - firstPart.length), + colorMap.length - (effectiveStops.length - firstPart.length), ); // Create the new array by combining the first and last parts @@ -135,7 +156,7 @@ export namespace Utils { } for (let i = 0; i < nClasses; i++) { - valueColorPairs.push({ stop: stops[i], output: colorMap[i] }); + valueColorPairs.push({ stop: effectiveStops[i], output: colorMap[i] }); } return valueColorPairs; diff --git a/packages/base/src/dialogs/symbology/tiff_layer/components/BandRow.tsx b/packages/base/src/dialogs/symbology/tiff_layer/components/BandRow.tsx index 62036c54b..5b606da1e 100644 --- a/packages/base/src/dialogs/symbology/tiff_layer/components/BandRow.tsx +++ b/packages/base/src/dialogs/symbology/tiff_layer/components/BandRow.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React from 'react'; -import { IBandRow } from '@/src/dialogs/symbology/hooks/useGetBandInfo'; +import { IBandRow } from '@/src/dialogs/symbology/hooks/useGetMultiBandInfo'; interface IBandRowProps { label: string; @@ -8,7 +8,7 @@ interface IBandRowProps { bandRow: IBandRow; bandRows: IBandRow[]; setSelectedBand: (band: number) => void; - setBandRows: (bandRows: IBandRow[]) => void; + setBandRows?: (bandRows: IBandRow[]) => void; isMultibandColor?: boolean; } @@ -28,33 +28,8 @@ const BandRow: React.FC = ({ bandRow, bandRows, setSelectedBand, - setBandRows, isMultibandColor, }) => { - const [minValue, setMinValue] = useState(bandRow?.stats.minimum); - const [maxValue, setMaxValue] = useState(bandRow?.stats.maximum); - - const handleMinValueChange = (event: { - target: { value: string | number }; - }) => { - setMinValue(+event.target.value); - setNewBands(); - }; - - const handleMaxValueChange = (event: { - target: { value: string | number }; - }) => { - setMaxValue(+event.target.value); - setNewBands(); - }; - - const setNewBands = () => { - const newBandRows = [...bandRows]; - newBandRows[index].stats.minimum = minValue; - newBandRows[index].stats.maximum = maxValue; - setBandRows(newBandRows); - }; - return ( <>
@@ -90,48 +65,6 @@ const BandRow: React.FC = ({
- {isMultibandColor ? null : ( -
-
- - -
-
- - -
-
- )} ); }; diff --git a/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx b/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx index 6465a8584..58501091c 100644 --- a/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx +++ b/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx @@ -2,7 +2,7 @@ import { IWebGlLayer } from '@jupytergis/schema'; import { ExpressionValue } from 'ol/expr/expression'; import React, { useEffect, useRef, useState } from 'react'; -import useGetBandInfo from '@/src/dialogs/symbology/hooks/useGetBandInfo'; +import useGetMultiBandInfo from '@/src/dialogs/symbology/hooks/useGetMultiBandInfo'; import { ISymbologyDialogProps } from '@/src/dialogs/symbology/symbologyDialog'; import BandRow from '@/src/dialogs/symbology/tiff_layer/components/BandRow'; import { LoadingOverlay } from '@/src/shared/components/loading'; @@ -30,7 +30,7 @@ const MultibandColor: React.FC = ({ return; } - const { bandRows, setBandRows, loading } = useGetBandInfo(model, layer); + const { bandRows, setBandRows, loading } = useGetMultiBandInfo(model, layer); const [selectedBands, setSelectedBands] = useState({ red: 1, diff --git a/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx b/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx index f72ae8a71..5f76da98a 100644 --- a/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx +++ b/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx @@ -9,9 +9,9 @@ import ColorRampControls, { ColorRampControlsOptions, } from '@/src/dialogs/symbology/components/color_ramp/ColorRampControls'; import StopRow from '@/src/dialogs/symbology/components/color_stops/StopRow'; -import useGetBandInfo, { +import useGetSingleBandInfo, { IBandRow, -} from '@/src/dialogs/symbology/hooks/useGetBandInfo'; +} from '@/src/dialogs/symbology/hooks/useGetSingleBandInfo'; import { IStopRow, ISymbologyDialogProps, @@ -20,6 +20,7 @@ import { Utils } from '@/src/dialogs/symbology/symbologyUtils'; import BandRow from '@/src/dialogs/symbology/tiff_layer/components/BandRow'; import { LoadingOverlay } from '@/src/shared/components/loading'; import { GlobalStateDbManager } from '@/src/store'; +import { ColorRampName } from '@/src/types'; export type InterpolationType = 'discrete' | 'linear' | 'exact'; @@ -42,11 +43,19 @@ const SingleBandPseudoColor: React.FC = ({ const stateDb = GlobalStateDbManager.getInstance().getStateDb(); - const { bandRows, setBandRows, loading } = useGetBandInfo(model, layer); + const [selectedBand, setSelectedBand] = useState(1); + const { bandRows, loading } = useGetSingleBandInfo( + model, + layer, + layerId, + selectedBand, + ); const [layerState, setLayerState] = useState(); - const [selectedBand, setSelectedBand] = useState(1); + const [stopRows, setStopRows] = useState([]); + const [dataMin, setDataMin] = useState(); + const [dataMax, setDataMax] = useState(); const [selectedFunction, setSelectedFunction] = useState('linear'); const [colorRampOptions, setColorRampOptions] = useState< @@ -85,6 +94,12 @@ const SingleBandPseudoColor: React.FC = ({ selectedBandRef.current = selectedBand; }, [stopRows, selectedFunction, colorRampOptions, selectedBand, layerState]); + useEffect(() => { + if (bandRows.length > 0) { + applyActualRange(selectedBand); + } + }, [selectedBand, bandRows]); + const populateOptions = async () => { const layerState = (await stateDb?.fetch( `jupytergis:${layerId}`, @@ -100,6 +115,19 @@ const SingleBandPseudoColor: React.FC = ({ setSelectedFunction(interpolation); }; + const applyActualRange = (bandIndex: number) => { + const currentBand = bandRows[bandIndex - 1]; + if (!currentBand || !currentBand.stats) { + return; + } + + const min = currentBand.stats.minimum; + const max = currentBand.stats.maximum; + + setDataMin(min); + setDataMax(max); + }; + const buildColorInfo = () => { // This it to parse a color object on the layer if (!layer.parameters?.color || !layerState) { @@ -172,14 +200,6 @@ const SingleBandPseudoColor: React.FC = ({ const isQuantile = colorRampOptionsRef.current?.selectedMode === 'quantile'; - const sourceInfo = source.parameters.urls[0]; - sourceInfo.min = bandRow.stats.minimum; - sourceInfo.max = bandRow.stats.maximum; - - source.parameters.urls[0] = sourceInfo; - - model.sharedModel.updateSource(sourceId, source); - // Update layer if (!layer.parameters) { return; @@ -252,8 +272,11 @@ const SingleBandPseudoColor: React.FC = ({ band: selectedBandRef.current, interpolation: selectedFunctionRef.current, colorRamp: colorRampOptionsRef.current?.selectedRamp, + reverse: colorRampOptionsRef.current?.reverseRamp, nClasses: colorRampOptionsRef.current?.numberOfShades, mode: colorRampOptionsRef.current?.selectedMode, + min: colorRampOptionsRef.current?.minValue, + max: colorRampOptionsRef.current?.maxValue, }; layer.parameters.symbologyState = symbologyState; @@ -284,19 +307,24 @@ const SingleBandPseudoColor: React.FC = ({ const buildColorInfoFromClassification = async ( selectedMode: string, numberOfShades: string, - selectedRamp: string, + selectedRamp: ColorRampName, + reverseRamp: boolean, setIsLoading: (isLoading: boolean) => void, + minValue: number, + maxValue: number, ) => { // Update layer state with selected options setColorRampOptions({ selectedRamp, + reverseRamp, numberOfShades, selectedMode, + minValue, + maxValue, }); let stops: number[] = []; - const currentBand = bandRows[selectedBand - 1]; const source = model.getSource(layer?.parameters?.source); const sourceInfo = source?.parameters?.urls[0]; const nClasses = selectedMode === 'continuous' ? 52 : +numberOfShades; @@ -314,16 +342,16 @@ const SingleBandPseudoColor: React.FC = ({ case 'continuous': stops = GeoTiffClassifications.classifyContinuousBreaks( nClasses, - currentBand.stats.minimum, - currentBand.stats.maximum, + minValue, + maxValue, selectedFunction, ); break; case 'equal interval': stops = GeoTiffClassifications.classifyEqualIntervalBreaks( nClasses, - currentBand.stats.minimum, - currentBand.stats.maximum, + minValue, + maxValue, selectedFunction, ); break; @@ -337,6 +365,10 @@ const SingleBandPseudoColor: React.FC = ({ stops, selectedRamp, nClasses, + reverseRamp, + 'Singleband Pseudocolor', + minValue, + maxValue, ); setStopRows(valueColorPairs); @@ -375,7 +407,6 @@ const SingleBandPseudoColor: React.FC = ({ bandRow={bandRows[selectedBand - 1]} bandRows={bandRows} setSelectedBand={setSelectedBand} - setBandRows={setBandRows} />
@@ -403,6 +434,7 @@ const SingleBandPseudoColor: React.FC = ({
+ {bandRows.length > 0 && ( = ({ classifyFunc={buildColorInfoFromClassification} showModeRow={true} showRampSelector={true} + renderType="Singleband Pseudocolor" + dataMin={dataMin} + dataMax={dataMax} /> )} +
diff --git a/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx b/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx index a03d520a5..d12eab2ba 100644 --- a/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx +++ b/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx @@ -1,9 +1,8 @@ import { IVectorLayer } from '@jupytergis/schema'; -import { ReadonlyJSONObject } from '@lumino/coreutils'; import { ExpressionValue } from 'ol/expr/expression'; import React, { useEffect, useRef, useState } from 'react'; -import ColorRampControls from '@/src/dialogs/symbology/components/color_ramp/ColorRampControls'; +import ColorRampControls, { ColorRampControlsOptions } from '@/src/dialogs/symbology/components/color_ramp/ColorRampControls'; import StopContainer from '@/src/dialogs/symbology/components/color_stops/StopContainer'; import { IStopRow, @@ -11,7 +10,7 @@ import { } from '@/src/dialogs/symbology/symbologyDialog'; import { Utils, VectorUtils } from '@/src/dialogs/symbology/symbologyUtils'; import ValueSelect from '@/src/dialogs/symbology/vector_layer/components/ValueSelect'; -import { SymbologyTab } from '@/src/types'; +import { ColorRampName, SymbologyTab } from '@/src/types'; const Categorized: React.FC = ({ model, @@ -24,12 +23,12 @@ const Categorized: React.FC = ({ }) => { const selectedAttributeRef = useRef(); const stopRowsRef = useRef(); - const colorRampOptionsRef = useRef(); + const colorRampOptionsRef = useRef(); const [selectedAttribute, setSelectedAttribute] = useState(''); const [stopRows, setStopRows] = useState([]); const [colorRampOptions, setColorRampOptions] = useState< - ReadonlyJSONObject | undefined + ColorRampControlsOptions | undefined >(); const [manualStyle, setManualStyle] = useState({ fillColor: '#3399CC', @@ -38,14 +37,15 @@ const Categorized: React.FC = ({ radius: 5, }); const manualStyleRef = useRef(manualStyle); - const [reverseRamp, setReverseRamp] = useState(false); + const [dataMin, setDataMin] = useState(); + const [dataMax, setDataMax] = useState(); if (!layerId) { - return; + return null; } const layer = model.getLayer(layerId); if (!layer?.parameters) { - return; + return null; } useEffect(() => { @@ -108,6 +108,15 @@ const Categorized: React.FC = ({ Object.keys(selectableAttributesAndValues)[0]; setSelectedAttribute(attribute); + + const values = Array.from(selectableAttributesAndValues[attribute] ?? []); + if (values.length > 0) { + const min = Math.min(...values); + const max = Math.max(...values); + + setDataMin(min); + setDataMax(max); + } }, [selectableAttributesAndValues]); useEffect(() => { @@ -119,14 +128,19 @@ const Categorized: React.FC = ({ const buildColorInfoFromClassification = ( selectedMode: string, numberOfShades: string, - selectedRamp: string, + selectedRamp: ColorRampName, + reverseRamp: boolean, setIsLoading: (isLoading: boolean) => void, + minValue: number, + maxValue: number, ) => { setColorRampOptions({ - selectedFunction: '', selectedRamp, - numberOfShades: '', - selectedMode: '', + reverseRamp, + numberOfShades, + selectedMode, + minValue, + maxValue, }); const stops = Array.from( @@ -138,6 +152,9 @@ const Categorized: React.FC = ({ selectedRamp, stops.length, reverseRamp, + 'Categorized', + minValue, + maxValue, ); setStopRows(valueColorPairs); @@ -181,10 +198,10 @@ const Categorized: React.FC = ({ renderType: 'Categorized', value: selectedAttributeRef.current, colorRamp: colorRampOptionsRef.current?.selectedRamp, + reverse: colorRampOptionsRef.current?.reverseRamp, nClasses: colorRampOptionsRef.current?.numberOfShades, mode: colorRampOptionsRef.current?.selectedMode, symbologyTab, - reverse: reverseRamp, }; layer.parameters.symbologyState = symbologyState; @@ -316,19 +333,6 @@ const Categorized: React.FC = ({ )}
- {symbologyTab === 'color' && ( -
- -
- )} -
= ({ classifyFunc={buildColorInfoFromClassification} showModeRow={false} showRampSelector={symbologyTab === 'color'} + renderType="Categorized" + dataMin={dataMin} + dataMax={dataMax} /> = ({ model, @@ -50,7 +51,8 @@ const Graduated: React.FC = ({ const [radiusManualStyle, setRadiusManualStyle] = useState({ radius: 5, }); - const [reverseRamp, setReverseRamp] = useState(false); + const [dataMin, setDataMin] = useState(); + const [dataMax, setDataMax] = useState(); const colorManualStyleRef = useRef(colorManualStyle); const radiusManualStyleRef = useRef(radiusManualStyle); @@ -128,6 +130,15 @@ const Graduated: React.FC = ({ Object.keys(selectableAttributesAndValues)[0]; setSelectedAttribute(attribute); + + const values = Array.from(selectableAttributesAndValues[attribute] ?? []); + if (values.length > 0) { + const min = Math.min(...values); + const max = Math.max(...values); + + setDataMin(min); + setDataMax(max); + } }, [selectableAttributesAndValues]); const updateStopRowsBasedOnLayer = () => { @@ -192,9 +203,11 @@ const Graduated: React.FC = ({ value: selectableAttributeRef.current, method: symbologyTabRef.current, colorRamp: colorRampOptionsRef.current?.selectedRamp, + reverse: colorRampOptionsRef.current?.reverseRamp, nClasses: colorRampOptionsRef.current?.numberOfShades, mode: colorRampOptionsRef.current?.selectedMode, - reverse: reverseRamp, + min: colorRampOptionsRef.current?.minValue, + max: colorRampOptionsRef.current?.maxValue, }; if (layer.type === 'HeatmapLayer') { @@ -208,12 +221,21 @@ const Graduated: React.FC = ({ const buildColorInfoFromClassification = ( selectedMode: string, numberOfShades: string, - selectedRamp: string, + selectedRamp: ColorRampName, + reverseRamp: boolean, + setIsLoading: (isLoading: boolean) => void, + minValue: number, + maxValue: number, + criticalValue?: number, ) => { setColorRampOptions({ selectedRamp, + reverseRamp, numberOfShades, selectedMode, + minValue, + maxValue, + criticalValue, }); let stops: number[]; @@ -229,8 +251,9 @@ const Graduated: React.FC = ({ break; case 'equal interval': stops = VectorClassifications.calculateEqualIntervalBreaks( - values, +numberOfShades, + minValue, + maxValue, ); break; case 'jenks': @@ -258,12 +281,24 @@ const Graduated: React.FC = ({ const stopOutputPairs = symbologyTab === 'radius' - ? stops.map(v => ({ stop: v, output: v })) + ? stops.map(v => { + const scaled = + minValue !== undefined && maxValue !== undefined + ? minValue + + ((v - Math.min(...stops)) / + (Math.max(...stops) - Math.min(...stops))) * + (maxValue - minValue) + : v; + return { stop: scaled, output: scaled }; + }) : Utils.getValueColorPairs( stops, selectedRamp, +numberOfShades, reverseRamp, + 'Graduated', + minValue, + maxValue, ); if (symbologyTab === 'radius') { @@ -271,6 +306,8 @@ const Graduated: React.FC = ({ } else { setColorStopRows(stopOutputPairs); } + + setIsLoading(false); }; const handleReset = (method: string) => { @@ -368,25 +405,15 @@ const Graduated: React.FC = ({ )}
- {symbologyTab === 'color' && ( -
- -
- )} - = ({ radius: 8, blur: 15, }); - const reverseRampRef = useRef(false); + const reverseRampRef = useRef(false); // Do we need these refs here? Why not directly use the state? useEffect(() => { populateOptions(); @@ -102,18 +102,10 @@ const Heatmap: React.FC = ({
-
- -
0) { for (let i = 0; i < urls.length; i++) { - const { url, min, max } = urls[i]; + const { url } = urls[i]; if (this._isSubmitted) { const mimeType = getMimeType(url); if (!mimeType || !mimeType.startsWith('image/tiff')) { @@ -110,33 +110,10 @@ export class GeoTiffSourcePropertiesForm extends SourcePropertiesForm { `URL at index ${i} is required and must be a valid string.`, ); } - - if (min === undefined || typeof min !== 'number') { - errors.push( - `Min value at index ${i} is required and must be a number.`, - ); - valid = false; - } - - if (max === undefined || typeof max !== 'number') { - errors.push( - `Max value at index ${i} is required and must be a number.`, - ); - valid = false; - } - - if ( - typeof min === 'number' && - typeof max === 'number' && - max <= min - ) { - errors.push(`Max value at index ${i} must be greater than Min.`); - valid = false; - } } } } else { - errors.push('At least one valid URL with min/max values is required.'); + errors.push('At least one valid URL is required.'); valid = false; } diff --git a/packages/base/src/mainview/mainView.tsx b/packages/base/src/mainview/mainView.tsx index c863d0b15..6f3769f32 100644 --- a/packages/base/src/mainview/mainView.tsx +++ b/packages/base/src/mainview/mainView.tsx @@ -97,7 +97,13 @@ import AnnotationFloater from '@/src/annotations/components/AnnotationFloater'; import { CommandIDs } from '@/src/constants'; import { LoadingOverlay } from '@/src/shared/components/loading'; import StatusBar from '@/src/statusbar/StatusBar'; -import { debounce, isLightTheme, loadFile, throttle } from '@/src/tools'; +import { + debounce, + isLightTheme, + loadFile, + objectEntries, + throttle, +} from '@/src/tools'; import CollaboratorPointers, { ClientPointer } from './CollaboratorPointers'; import { FollowIndicator } from './FollowIndicator'; import TemporalSlider from './TemporalSlider'; @@ -281,6 +287,23 @@ export class MainView extends React.Component { this._mainViewModel.dispose(); } + /** + * Identify which layer uses a source. + * + * Relies on the assumption that sources and layers have a 1:1 relationship. + */ + getLayerIdForSourceId(sourceId: string): string { + const layers = this._model.sharedModel.layers; + + for (const [layerId, layer] of objectEntries(layers)) { + if (layer.parameters?.source === sourceId) { + return layerId; + } + } + + throw new Error(`No layer found for source ID: ${sourceId}`); + } + async generateMap(center: number[], zoom: number): Promise { if (this.divRef.current) { this._Map = new OlMap({ @@ -762,6 +785,10 @@ export class MainView extends React.Component { } case 'GeoTiffSource': { const sourceParameters = source.parameters as IGeoTiffSource; + const layerId = this.getLayerIdForSourceId(id); + const layer = layerId + ? this._model.sharedModel.getLayer(layerId) + : undefined; const addNoData = (url: (typeof sourceParameters.urls)[0]) => { return { ...url, nodata: 0 }; @@ -775,9 +802,9 @@ export class MainView extends React.Component { if (isRemote) { return { ...addNoData(sourceInfo), - min: sourceInfo.min, - max: sourceInfo.max, url: sourceInfo.url, + min: layer?.parameters?.symbologyState?.min, + max: layer?.parameters?.symbologyState?.max, }; } else { const geotiff = await loadFile({ @@ -787,15 +814,31 @@ export class MainView extends React.Component { }); return { ...addNoData(sourceInfo), - min: sourceInfo.min, - max: sourceInfo.max, geotiff, url: URL.createObjectURL(geotiff.file), + min: layer?.parameters?.symbologyState?.min, + max: layer?.parameters?.symbologyState?.max, }; } }), ); + if (layer && layer.parameters) { + if (!layer.parameters.symbologyState) { + layer.parameters.symbologyState = { + renderType: 'Singleband Pseudocolor', + }; + } else { + // Backwards compatibility for older projects that have min/max at + // source.parameters.urls[0] (i.e. before + // https://github.com/geojupyter/jupytergis/pull/912) + layer.parameters.symbologyState.min ??= + sourceParameters.urls[0]?.min; + layer.parameters.symbologyState.max ??= + sourceParameters.urls[0]?.max; + } + } + newSource = new GeoTIFFSource({ interpolate: sourceParameters.interpolate, sources, @@ -876,7 +919,7 @@ export class MainView extends React.Component { */ async updateSource(id: string, source: IJGISSource): Promise { // get the layer id associated with this source - const layerId = this._sourceToLayerMap.get(id); + const layerId = this.getLayerIdForSourceId(id); // get the OL layer const mapLayer = this.getLayer(layerId); if (!mapLayer) { @@ -1112,12 +1155,6 @@ export class MainView extends React.Component { // STAC layers don't have source if (newMapLayer instanceof Layer) { - // we need to keep track of which source has which layers - // Only set sourceToLayerMap if 'source' exists on layerParameters - if ('source' in layerParameters) { - this._sourceToLayerMap.set(layerParameters.source, id); - } - this.addProjection(newMapLayer); await this._waitForSourceReady(newMapLayer); } @@ -1369,6 +1406,23 @@ export class MainView extends React.Component { color: layer.parameters.color, }); } + + // Update source when symbologyState.min/max changes + const sourceId = layer.parameters?.source; + if (sourceId && layer?.parameters?.symbologyState) { + const min = layer.parameters.symbologyState.min; + const max = layer.parameters.symbologyState.max; + const oldMin = oldLayer?.parameters?.symbologyState?.min; + const oldMax = oldLayer?.parameters?.symbologyState?.max; + + if (min !== oldMin || max !== oldMax) { + const sourceModel = + this._model.sharedModel.getLayerSource(sourceId); + if (sourceModel) { + this.updateSource(sourceId, sourceModel); + } + } + } break; } case 'HeatmapLayer': { @@ -2396,7 +2450,6 @@ export class MainView extends React.Component { private _mainViewModel: MainViewModel; private _ready = false; private _sources: Record; - private _sourceToLayerMap = new Map(); private _documentPath?: string; private _contextMenu: ContextMenu; private _loadingLayers: Set; diff --git a/packages/base/src/panelview/components/legendItem.tsx b/packages/base/src/panelview/components/legendItem.tsx index c48e8143b..664f49a01 100644 --- a/packages/base/src/panelview/components/legendItem.tsx +++ b/packages/base/src/panelview/components/legendItem.tsx @@ -1,6 +1,7 @@ import { IJupyterGISModel } from '@jupytergis/schema'; import React, { useEffect, useState } from 'react'; +import { getColorMapList } from '@/src/dialogs/symbology/colorRampUtils'; import { useGetSymbology } from '@/src/dialogs/symbology/hooks/useGetSymbology'; export const LegendItem: React.FC<{ @@ -121,6 +122,11 @@ export const LegendItem: React.FC<{ return; } + const rampName = symbology.symbologyState?.colorRamp; + const rampDef = rampName + ? getColorMapList().find(c => c.name === rampName) + : undefined; + const segments = stops .map((s, i) => { const pct = (i / (stops.length - 1)) * 100; @@ -129,6 +135,17 @@ export const LegendItem: React.FC<{ .join(', '); const gradient = `linear-gradient(to right, ${segments})`; + const dataMin = symbology.symbologyState.dataMin ?? stops[0].value; + const dataMax = + symbology.symbologyState.dataMax ?? stops[stops.length - 1].value; + + let criticalValue: number | undefined = undefined; + if (rampDef?.definition.type === 'Divergent') { + const relativeCritical = symbology.symbologyState.criticalValue ?? 0.5; + criticalValue = dataMin + relativeCritical * (dataMax - dataMin); + } + const isDivergent = criticalValue !== undefined; + setContent(
{property && ( @@ -147,16 +164,50 @@ export const LegendItem: React.FC<{ marginTop: 10, }} > - {stops.map((s, i) => { - const left = (i / (stops.length - 1)) * 100; - const up = i % 2 === 0; - return ( + {!isDivergent ? ( + stops.map((s, i) => { + const left = (i / (stops.length - 1)) * 100; + const up = i % 2 === 0; + return ( +
+
+
+ {s.value.toFixed(2)} +
+
+ ); + }) + ) : ( + <> + {/* Min */}
+
+ {dataMin.toFixed(2)} +
+
+ + {/* Max */} +
+
- {s.value.toFixed(2)} + {dataMax.toFixed(2)}
- ); - })} + + {/* Critical */} + {isDivergent && criticalValue !== undefined && ( +
= dataMax + ? 100 + : ((criticalValue - dataMin) / + (dataMax - dataMin)) * + 100 + }%`, + transform: 'translateX(-50%)', + }} + > +
+
+ {criticalValue.toFixed(1)} +
+
+ )} + + )}
, ); @@ -197,6 +304,17 @@ export const LegendItem: React.FC<{ return; } + const numericCats = cats + .map(c => (typeof c.category === 'number' ? c.category : NaN)) + .filter(v => !isNaN(v)); + + const minValue = numericCats.length + ? Math.min(...numericCats) + : undefined; + const maxValue = numericCats.length + ? Math.max(...numericCats) + : undefined; + setContent(
{property && ( @@ -204,6 +322,7 @@ export const LegendItem: React.FC<{ {property}
)} +
{cats.map((c, i) => ( @@ -227,10 +347,24 @@ export const LegendItem: React.FC<{ borderRadius: 2, }} /> - {String(c.category)} + + {typeof c.category === 'number' + ? c.category.toFixed(2) + : String(c.category)} +
))}
+ + {/* Min/Max */} + {(minValue !== undefined || maxValue !== undefined) && ( +
+ {minValue !== undefined &&
Min: {minValue}
} + {maxValue !== undefined &&
Max: {maxValue}
} +
+ )}
, ); return; diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index 595e63df8..e81a93886 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -31,6 +31,9 @@ export const debounce = ( }; }; +export const objectKeys = Object.keys as >( + obj: T, +) => Array; export function throttle void>( callback: T, delay = 100, @@ -995,7 +998,7 @@ export async function getGeoJSONDataFromLayerSource( * code when using it. */ export const objectEntries = Object.entries as < - T extends Record, + T extends Record, >( obj: T, ) => Array<{ [K in keyof T]: [K, T[K]] }[keyof T]>; diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index 6ae72d4b2..6e94aba31 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -2,6 +2,8 @@ import { IDict, IJupyterGISWidget } from '@jupytergis/schema'; import { WidgetTracker } from '@jupyterlab/apputils'; import { Map } from 'ol'; +import { COLOR_RAMP_DEFINITIONS } from '@/src/dialogs/symbology/colorRamps'; + export { IDict }; export type ValueOf = T[keyof T]; @@ -40,3 +42,47 @@ declare global { jupytergisMaps: { [name: string]: Map }; } } + +/** + * Color ramp types and definitions + */ +export type ColorRampType = 'Sequential' | 'Divergent' | 'Cyclic'; + +export interface IBaseColorRampDefinition { + type: ColorRampType; +} + +export interface ISequentialColorRampDefinition + extends IBaseColorRampDefinition { + type: 'Sequential'; +} + +export interface IDivergentColorRampDefinition + extends IBaseColorRampDefinition { + type: 'Divergent'; + criticalValue: number; +} + +export interface ICyclicColorRampDefinition extends IBaseColorRampDefinition { + type: 'Cyclic'; +} + +export type IColorRampDefinition = + | ISequentialColorRampDefinition + | IDivergentColorRampDefinition + | ICyclicColorRampDefinition; + +export interface IColorMap { + name: ColorRampName; + colors: string[]; + definition: IColorRampDefinition; +} + +export type ColorRampName = keyof typeof COLOR_RAMP_DEFINITIONS; + +export const COLOR_RAMP_DEFAULTS: Partial> = { + hsv: 11, + picnic: 11, + 'rainbow-soft': 11, + cubehelix: 16, +}; diff --git a/packages/base/style/base.css b/packages/base/style/base.css index ba07d8062..224345ccd 100644 --- a/packages/base/style/base.css +++ b/packages/base/style/base.css @@ -65,6 +65,11 @@ position: relative; } +input.jp-mod-styled:disabled { + opacity: 0.5; + pointer-events: none; +} + /*This is being upstreamed. Will remove once upstream's been fixed.*/ button.jp-mod-styled.jp-mod-accept { background-color: var(--jp-brand-color1) !important; diff --git a/packages/base/style/symbologyDialog.css b/packages/base/style/symbologyDialog.css index 0019a214d..65a73a514 100644 --- a/packages/base/style/symbologyDialog.css +++ b/packages/base/style/symbologyDialog.css @@ -23,7 +23,10 @@ select option { .jp-gis-symbology-row label { font-size: var(--jp-ui-font-size2); - flex: 0 1 20%; + flex: 0 1 auto; + display: inline-flex; + align-items: center; + gap: 0.4rem; } .jp-gis-symbology-row > .jp-select-wrapper, @@ -74,6 +77,8 @@ select option { display: flex; flex-direction: column; gap: 13px; + border-top: 1px solid var(--jp-border-color2); + padding-top: 8px; } .jp-gis-stop-container { diff --git a/packages/schema/src/schema/project/sources/geoTiffSource.json b/packages/schema/src/schema/project/sources/geoTiffSource.json index cc05529f4..66bac9b05 100644 --- a/packages/schema/src/schema/project/sources/geoTiffSource.json +++ b/packages/schema/src/schema/project/sources/geoTiffSource.json @@ -12,12 +12,6 @@ "properties": { "url": { "type": "string" - }, - "min": { - "type": "number" - }, - "max": { - "type": "number" } } },