diff --git a/eslint.config.js b/eslint.config.js index 82c2e20..9ea1173 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,14 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, } ); diff --git a/src/App.tsx b/src/App.tsx index eb44958..8cb5971 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,14 @@ -import { useCallback, useMemo, useState, useReducer, ChangeEvent } from 'react'; -import { MapGroupResponse, SourceGroup, Box } from './types/maps'; +import { useState, useReducer } from 'react'; +import { DefaultData } from './types/layers'; import { ColorMapControls } from './components/ColorMapControls'; -import { - fetchBoxes, - fetchMaps, - fetchSources, - getHistogramData, -} from './utils/fetchUtils'; +import { mapApi } from './api/client'; import { assertInternalBaselayer, baselayersReducer, - CHANGE_CMAP_TYPE, - CHANGE_CMAP_VALUES, - CHANGE_LOG_SCALE, - CHANGE_ABSOLUTE_VALUE, initialBaselayersState, SET_BASELAYERS_STATE, } from './reducers/baselayersReducer'; import { useQuery } from './hooks/useQuery'; -import { useBaselayerChange } from './hooks/useBaselayerChange'; import { OpenLayersMap } from './components/OpenLayersMap'; import { Login } from './components/Login'; import { LoadingOverlay } from './components/LoadingOverlay'; @@ -32,213 +22,55 @@ function App() { const [isAuthenticated, setIsAuthenticated] = useState(null); - const [flipTiles, setFlipTiles] = useState(true); - - /** query the map groups to use as the baselayers of the map */ - const { data: mapGroups, isLoading: areMapGroupsLoading } = useQuery< - MapGroupResponse[] | undefined + /** Fetch the default state to use as the initial baselayer and layer menu hierarchy */ + const { data: defaultData, isLoading: isInitializing } = useQuery< + DefaultData | undefined >({ initialData: undefined, queryKey: [isAuthenticated], queryFn: async () => { - // Fetch the maps and the map metadata in order to get the list of bands used as - // map baselayers - const { mapGroups, internalBaselayers } = await fetchMaps(); + const { defaultMenuState, defaultLayer } = await mapApi.getInitialState(); - if (!mapGroups.length || !internalBaselayers.length) { - // If we end up with no maps, SET_BASELAYERS_STATE will fall back to an external baselayer as its default initial baselayer + if (!defaultLayer) { + // If default state errors or is null, SET_BASELAYERS_STATE will fall back to an external baselayer as its default initial baselayer dispatchBaselayersChange({ type: SET_BASELAYERS_STATE, - internalBaselayers: [], + defaultInternalBaselayer: undefined, histogramData: undefined, }); } else { // Otherwise, get what will be the default baselayer's histogram data to set in the reducer state - const defaultInitialBaselayer = { ...internalBaselayers[0] }; - const histogramData = await getHistogramData( - defaultInitialBaselayer.layer_id + const histogramData = await mapApi.getHistogramData( + defaultLayer.layer_id ); // Check if the default baselayer has an undefined vmin or vmax; if so, set the // vmin and vmax for the baselayer if ( - defaultInitialBaselayer.vmin === undefined || - defaultInitialBaselayer.vmax === undefined + defaultLayer.vmin === undefined || + defaultLayer.vmax === undefined ) { - const histogramData = await getHistogramData( - defaultInitialBaselayer.layer_id - ); - defaultInitialBaselayer.vmin = histogramData.vmin; - defaultInitialBaselayer.vmax = histogramData.vmax; - internalBaselayers[0] = defaultInitialBaselayer; + defaultLayer.vmin = histogramData.vmin; + defaultLayer.vmax = histogramData.vmax; } - // Set the baselayersState with the internalBaselayers; note that this action will also set the - // activeBaselayer to be the first element in internalBaselayers + // Set the baselayersState with the default baselayer; note that this action will also set the + // activeBaselayer to be the default baselayer dispatchBaselayersChange({ type: SET_BASELAYERS_STATE, - internalBaselayers: internalBaselayers, + defaultInternalBaselayer: defaultLayer, histogramData, }); } - return mapGroups; - }, - }); - - /** sourceLists are used as FeatureGroups in the map, which can be toggled on/off in the map legend */ - const { data: sourceGroups, isLoading: areSourceGroupsLoading } = useQuery< - SourceGroup[] | undefined - >({ - initialData: undefined, - queryKey: [isAuthenticated], - queryFn: async () => { - // Fetch the sources - const sourceGroups = await fetchSources(); - - return sourceGroups; - }, - }); - - /** highlight boxes allow users to download submaps and to highlight regions of the map */ - const { data: highlightBoxes, isLoading: areHighlightBoxesLoading } = - useQuery({ - initialData: undefined, - queryKey: [isAuthenticated], - queryFn: async () => { - // Fetch the highlight boxes - const boxes = await fetchBoxes(); - - return boxes; - }, - }); - - /** tracks highlight boxes that are "checked" and visible on the map */ - const [activeBoxIds, setActiveBoxIds] = useState([]); - - const [activeSourceGroupIds, setActiveSourceGroupIds] = useState( - [] - ); - - const onSelectedSourceGroupsChange = useCallback( - (e: ChangeEvent) => { - if (!sourceGroups) return; - if (e.target.checked) { - setActiveSourceGroupIds((prevState) => - prevState.concat(e.target.value) - ); - } else { - setActiveSourceGroupIds((prevState) => - prevState.filter((id) => id !== e.target.value) - ); - } - }, - [sourceGroups] - ); - - const onSelectedHighlightBoxChange = useCallback( - (e: ChangeEvent) => { - if (!highlightBoxes) return; - if (e.target.checked) { - setActiveBoxIds((prevState) => - prevState.concat(Number(e.target.value)) - ); - } else { - setActiveBoxIds((prevState) => - prevState.filter((id) => id !== Number(e.target.value)) - ); - } - }, - [highlightBoxes] - ); - - const onCmapValuesChange = useCallback( - (values: number[]) => { - if (baselayersState.activeBaselayer) { - dispatchBaselayersChange({ - type: CHANGE_CMAP_VALUES, - activeBaselayer: baselayersState.activeBaselayer, - vmin: values[0], - vmax: values[1], - }); - } - }, - [baselayersState.activeBaselayer] - ); - - const onCmapChange = useCallback( - (cmap: string) => { - if (baselayersState.activeBaselayer) { - dispatchBaselayersChange({ - type: CHANGE_CMAP_TYPE, - activeBaselayer: baselayersState.activeBaselayer, - cmap, - }); - } - }, - [baselayersState.activeBaselayer] - ); - - /** Creates an object of data needed by the submap endpoints to download and to add regions. Since it's - composed from state at this level, we must construct it here and pass it down. */ - const submapData = useMemo(() => { - if (assertInternalBaselayer(baselayersState.activeBaselayer)) { - const { layer_id, cmap, vmin, vmax, isLogScale, isAbsoluteValue } = - baselayersState.activeBaselayer; return { - layer_id, - vmin, - vmax, - cmap, - isLogScale, - isAbsoluteValue, + defaultMenuState, + defaultLayer, }; - } - }, [baselayersState.activeBaselayer]); - - const onLogScaleChange = useCallback( - (checked: boolean) => { - if (baselayersState.activeBaselayer) { - dispatchBaselayersChange({ - type: CHANGE_LOG_SCALE, - activeBaselayer: baselayersState.activeBaselayer, - isLogScale: checked, - }); - } }, - [baselayersState.activeBaselayer] - ); - - const onAbsoluteValueChange = useCallback( - (checked: boolean) => { - if (baselayersState.activeBaselayer) { - dispatchBaselayersChange({ - type: CHANGE_ABSOLUTE_VALUE, - activeBaselayer: baselayersState.activeBaselayer, - isAbsoluteValue: checked, - }); - } - }, - [baselayersState.activeBaselayer] - ); - - const { - changeBaselayer, - goBack, - goForward, - optimisticBaselayerId, - isPending, - disableGoBack, - disableGoForward, - } = useBaselayerChange( - baselayersState, - dispatchBaselayersChange, - flipTiles, - setFlipTiles - ); + }); - const { activeBaselayer, internalBaselayers, histogramData } = - baselayersState; + const { activeBaselayer, histogramData } = baselayersState; return ( <> @@ -246,60 +78,24 @@ function App() { isAuthenticated={isAuthenticated} setIsAuthenticated={setIsAuthenticated} /> - {isAuthenticated !== null && - activeBaselayer && - internalBaselayers && - mapGroups && ( - - )} + {isAuthenticated !== null && activeBaselayer && defaultData && ( + + )} {isAuthenticated !== null && assertInternalBaselayer(activeBaselayer) && - activeBaselayer.vmin !== undefined && - activeBaselayer.vmax !== undefined && histogramData && ( )} - + ); } diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..f22ec6c --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,224 @@ +import { SERVICE_URL } from '../configs/mapSettings'; +import { HistogramResponse } from '../types/histogram'; +import { + BandSummary, + InternalBaselayer, + LayerSummary, + FilterMenuResponse, + MapSummary, + LayerResponse, + DefaultDataResponse, + DefaultData, +} from '../types/layers'; +import { Box, BoxResponse, SubmapDataWithBounds } from '../types/submaps'; +import { + SourceGroup, + SourceGroupResponse, + SourceGroupSummaryResponse, +} from '../types/sources'; +import { SubmapFileExtensions } from '../configs/submapConfigs'; + +class MapApiClient { + private baseUrl: string; + private cachedLayerIds = new Set(); + private cmapCache = new Map(); + private histogramCache = new Map(); + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } + + private async get(path: string, signal?: AbortSignal): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { signal }); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json(); + } + + /** Get initial state for app's startup */ + async getInitialState(): Promise { + // Get the default layer + const initialState = await this.get(`/layers/default`); + const { layer, default_layer_menu } = initialState; + + let defaultLayer: InternalBaselayer | null; + + // If any nullable state is null, set defaultLayer to be null + if (layer === null) { + defaultLayer = null; + } else { + this.cachedLayerIds.add(layer.layer_id); + + // Set to undefined if 'auto' so we can know to set this value + // with the layer's histogram response instead + const vmin = layer.vmin === 'auto' ? undefined : layer.vmin; + const vmax = layer.vmax === 'auto' ? undefined : layer.vmax; + + defaultLayer = { + ...layer, + isLogScale: false, + isAbsoluteValue: false, + vmin, + vmax, + }; + } + + return { + defaultLayer, + defaultMenuState: default_layer_menu, + }; + } + + /** Get summary of maps associated with a map group */ + async getMapGroupMaps(groupId: string, signal?: AbortSignal) { + return this.get(`/map-groups/${groupId}/maps`, signal); + } + + /** Get summary of bands associated with a map */ + async getMapBands(mapId: string, signal?: AbortSignal) { + return this.get(`/maps/${mapId}/bands`, signal); + } + + /** Get summary of layers associated with a band */ + async getBandLayers(bandId: string, signal?: AbortSignal) { + return this.get(`/bands/${bandId}/layers`, signal); + } + + /** Get a layer's full data; uses a cache to prevent unnecessary requests */ + async getLayer( + layerId: string, + internalBaselayers: Map | undefined + ) { + const isCached = this.cachedLayerIds.has(layerId); + if (isCached) { + return internalBaselayers?.get(layerId); + } else { + this.cachedLayerIds.add(layerId); + const newLayerData = await this.get(`/layers/${layerId}`); + + // Set to undefined if 'auto' so we can know to set this value + // with the layer's histogram response instead + const vmin = newLayerData.vmin === 'auto' ? undefined : newLayerData.vmin; + const vmax = newLayerData.vmax === 'auto' ? undefined : newLayerData.vmax; + + const newLayer = { + ...newLayerData, + isLogScale: false, + isAbsoluteValue: false, + vmin, + vmax, + }; + + return newLayer; + } + } + + /** Get the data of the source groups and their sources */ + async getSources(): Promise { + // Get the list of source groups and unpack the response + const sourceGroupSummaries = + await this.get(`/sources`); + + const fullSourceGroupsData = await Promise.all( + // For each source group, fetch its sources and construct SourceGroup data structure + sourceGroupSummaries.map(async (sg, idx): Promise => { + const sourceGroup = await this.get( + `/sources/${sg.source_group_id}` + ); + const fullData = { + clientId: idx, // used for color mapping in legend and source markers + ...sourceGroup, + }; + return fullData; + }) + ); + return fullSourceGroupsData; + } + + async getHighlightBoxes() { + const boxes = await this.get(`/highlights/boxes`); + return boxes.map((b, idx): Box => ({ ...b, id: idx })); + } + + /** Get histogram data; uses a cache to only fetch the data once */ + async getHistogramData(layerId: string): Promise { + if (this.histogramCache.has(layerId)) { + return this.histogramCache.get(layerId)!; + } + const data = await this.get( + `/histograms/data/${layerId}` + ); + this.histogramCache.set(layerId, data); + return data; + } + + /** Get cmap image; uses a cache to prevent needless server requests */ + async getCmapImage(cmap: string) { + if (this.cmapCache.has(cmap)) { + return this.cmapCache.get(cmap) as string; + } + const image = await fetch(`${this.baseUrl}/histograms/${cmap}.png`); + this.cmapCache.set(cmap, image.url); + return image.url; + } + + /** Get menu state based on a search query */ + async getFilteredMenu(query: string) { + return this.get(`/search?q=${query}`); + } + + /** + * Downloads submap from the "select region" feature or from a box region selected in the layer menu + * @param submapEndpointStub A stubbed string of the endpoint that contains the mapId and bandId of + * the active baselayer, plus the left, right, top, and bottom positions of the selected region + * @param fileExtension One of the string literals defined in SubmapFileExtensions + * @returns Nothing as of now + */ + async downloadSubmap( + submapDataWithBounds: SubmapDataWithBounds, + fileExtension: SubmapFileExtensions, + flip: boolean + ) { + // Use the submapEndpointData to construct the request endpoint + const { + layer_id, + left, + right, + top, + bottom, + vmin, + vmax, + cmap, + isLogScale, + isAbsoluteValue, + } = submapDataWithBounds; + const endpoint = `${this.baseUrl}/layers/${layer_id}/submap/${left}/${right}/${top}/${bottom}/image.${fileExtension}?cmap=${cmap}&vmin=${vmin}&vmax=${vmax}&log_norm=${isLogScale}&abs=${isAbsoluteValue}&flip=${flip}`; + + await fetch(endpoint) + .then((response) => { + if (!response.ok) { + throw new Error(`Error downloading the submap: ${response.status}`); + } + return response.blob(); + }) + .then((blob) => { + // Create a URL for the blob + const url = window.URL.createObjectURL(blob); + + // Create a temporary anchor element to trigger the download + const a = document.createElement('a'); + a.href = url; + a.download = `tileviewer-submap.${fileExtension}`; // Give it a filename + document.body.appendChild(a); + a.click(); + a.remove(); + + // Clean up the blob URL + window.URL.revokeObjectURL(url); + }) + .catch((error) => { + console.error('Error downloading the file:', error); + }); + } +} + +export const mapApi = new MapApiClient(SERVICE_URL); diff --git a/src/components/BoxMenu.tsx b/src/components/BoxMenu.tsx index fddbcc9..70290df 100644 --- a/src/components/BoxMenu.tsx +++ b/src/components/BoxMenu.tsx @@ -1,13 +1,13 @@ import { ReactNode, useCallback } from 'react'; -import { BoxWithDimensions, NewBoxData, SubmapData } from '../types/maps'; +import { BoxWithDimensions, NewBoxData, SubmapData } from '../types/submaps'; import { MenuIcon } from './icons/MenuIcon'; import { SUBMAP_DOWNLOAD_OPTIONS, SubmapFileExtensions, } from '../configs/submapConfigs'; -import { downloadSubmap } from '../utils/fetchUtils'; import { transformBoxCoords } from '../utils/layerUtils'; import { Map } from 'ol'; +import { mapApi } from '../api/client'; type BoxMenuProps = { isNewBox: boolean; @@ -43,7 +43,7 @@ export function BoxMenu({ (ext: SubmapFileExtensions) => { if (submapData) { const boxPosition = transformBoxCoords(boxData, flipped); - downloadSubmap( + mapApi.downloadSubmap( { ...submapData, top: boxPosition.top_left_dec, diff --git a/src/components/CollapsibleSection.tsx b/src/components/CollapsibleSection.tsx deleted file mode 100644 index e3ef094..0000000 --- a/src/components/CollapsibleSection.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { ReactNode, memo } from 'react'; -import { LayerSelectorProps } from './LayerSelector'; -import { ChevronRightIcon } from './icons/ChevronRightIcon'; -import { ChevronDownIcon } from './icons/ChevronDownIcon'; -import { - BandResponse, - LayerResponse, - MapGroupResponse, - MapResponse, -} from '../types/maps'; -import { getNodeId } from '../utils/filterUtils'; - -type Props = { - node: MapGroupResponse | MapResponse | BandResponse | LayerResponse; - nestedDepth: number; - onBaselayerChange: LayerSelectorProps['onBaselayerChange']; - activeBaselayerId: LayerSelectorProps['activeBaselayerId']; - searchText: string; - expandedState: Set; - markMatchingSearchText: ( - label: string, - shouldHighlight?: boolean - ) => string | ReactNode; - matchedIds: Set; - highlightMatch: boolean; - handleToggle: (id: string) => void; -}; - -function CollapsibleSection({ - node, - nestedDepth, - onBaselayerChange, - activeBaselayerId, - searchText, - expandedState, - markMatchingSearchText, - matchedIds, - highlightMatch, - handleToggle, -}: Props) { - let children; - const nodeId = getNodeId(node); - - if (expandedState.has(nodeId) || searchText.length > 0) { - if ('band_id' in node) { - children = ( -
- {(node as BandResponse).layers.map((layer) => ( - - ))} -
- ); - } else if ('map_id' in node) { - children = (node as MapResponse).bands.map((band) => ( - - )); - } else { - children = (node as MapGroupResponse).maps.map((map) => ( - - )); - } - } - - return ( -
-
handleToggle(nodeId)} - className="layer-title-container" - > - {expandedState.has(nodeId) ? : } - {markMatchingSearchText(node.name, highlightMatch)} -
- {children} -
- ); -} - -export default memo(CollapsibleSection); diff --git a/src/components/ColorMapControls.tsx b/src/components/ColorMapControls.tsx index 47290a9..8b4ee09 100644 --- a/src/components/ColorMapControls.tsx +++ b/src/components/ColorMapControls.tsx @@ -11,42 +11,47 @@ import { STEPS_DIVISOR, } from '../configs/cmapControlSettings'; import { ColorMapSlider } from './ColorMapSlider'; -import { HistogramResponse } from '../types/maps'; +import { HistogramResponse } from '../types/histogram'; import { ColorMapHistogram } from './ColorMapHistogram'; import { CustomColorMapDialog } from './CustomColorMapDialog'; import { safeLog } from '../utils/numberUtils'; import { getAbsoluteHistogramData } from '../utils/histogramUtils'; -import { getCmapImage } from '../utils/fetchUtils'; +import { + BaselayersAction, + CHANGE_LOG_SCALE, + CHANGE_ABSOLUTE_VALUE, + CHANGE_CMAP_TYPE, + CHANGE_CMAP_VALUES, +} from '../reducers/baselayersReducer'; +import { InternalBaselayer } from '../types/layers'; +import { mapApi } from '../api/client'; export type ColorMapControlsProps = { - /** the selected or default min and max values for the slider */ - values: number[]; - /** used to determine increment/decrement value for keyboard controls */ - cmapRange: number; - /** handler to set new user-specified values for slider */ - onCmapValuesChange: (values: number[]) => void; - /** the color map selected in the cmap selector */ - cmap: string; - /** handler to set new color map */ - onCmapChange: (cmap: string) => void; - /** the id of the selected map baselayer */ - activeBaselayerId: string; - /** the units to display in the histogram range */ - units?: string; - /** the quantity to display in the histogram range */ - quantity?: string; - /** whether or not cmap x-axis is log scale */ - isLogScale: boolean; - /** whether or not cmap x-axis is set to be absolute values */ - isAbsoluteValue: boolean; - /** handler to update isLogScale state and to convert cmap values */ - onLogScaleChange: (checked: boolean) => void; - /** handler to update isAbsoluteValue state and to set cmap values as necessary */ - onAbsoluteValueChange: (checked: boolean) => void; + activeBaselayer: InternalBaselayer; + dispatchBaselayersChange: React.ActionDispatch<[BaselayersAction]>; /** the histogramData that is fetched/cached with each baselayer change */ histogramData: HistogramResponse; }; +export type ColorMapConfigChangeAction = + | { + type: typeof CHANGE_LOG_SCALE; + isLogScale: boolean; + } + | { + type: typeof CHANGE_ABSOLUTE_VALUE; + isAbsoluteValue: boolean; + } + | { + type: typeof CHANGE_CMAP_TYPE; + cmap: string; + } + | { + type: typeof CHANGE_CMAP_VALUES; + vmin: number; + vmax: number; + }; + /** * A component that displays the ColorMapHistogram, along with components to control the histogram * settings: a range slider for quick adjustments and a CustomColorMapDialog for more fine-tuned @@ -55,30 +60,37 @@ export type ColorMapControlsProps = { * @returns ColorMapControls */ export function ColorMapControls(props: ColorMapControlsProps) { - const { - values, - cmapRange, - onCmapValuesChange, - cmap, - onCmapChange, - units, - quantity, - isLogScale, - isAbsoluteValue, - onLogScaleChange, - onAbsoluteValueChange, - histogramData, - } = props; + const { activeBaselayer, dispatchBaselayersChange, histogramData } = props; + const [cmapImage, setCmapImage] = useState(undefined); const [showCustomDialog, setShowCustomDialog] = useState(false); const [cmapOptions, setCmapOptions] = useState(CMAP_OPTIONS); + const { cmap, units, quantity, isLogScale, isAbsoluteValue } = + activeBaselayer; + + // This should be set in the parent component, so let's just assert here to keep Typescript happy + const vmin = activeBaselayer.vmin!; + const vmax = activeBaselayer.vmax!; + + const onColorMapConfigChange = useCallback( + (action: ColorMapConfigChangeAction) => { + if (activeBaselayer) { + dispatchBaselayersChange({ + ...action, + activeBaselayer, + }); + } + }, + [activeBaselayer, dispatchBaselayersChange] + ); + /** * Fetch or retrieve from cache the cmap image when user changes cmap selection */ useEffect(() => { async function getImage() { - const image = await getCmapImage(cmap); + const image = await mapApi.getCmapImage(cmap); setCmapImage(image); } getImage(); @@ -139,24 +151,35 @@ export function ColorMapControls(props: ColorMapControlsProps) { histogram edges divided by STEPS_DIVISOR. */ const sliderAttributes = useMemo(() => { if (!processedHistogramData?.edges) return; - const min = Math.min(...processedHistogramData.edges, values[0]); - const max = Math.max(...processedHistogramData.edges, values[1]); + const min = Math.min(...processedHistogramData.edges, vmin); + const max = Math.max(...processedHistogramData.edges, vmax); const stepCalc = (Math.abs(min) + Math.abs(max)) / STEPS_DIVISOR; const step = stepCalc >= 1 ? Math.floor(stepCalc) : stepCalc; return { min, max, step }; - }, [processedHistogramData?.edges, values]); + }, [processedHistogramData?.edges, vmin, vmax]); /** Change handler for the color map onAbsoluteValueChange(e.target.checked)} + onChange={(e) => + onColorMapConfigChange({ + type: CHANGE_ABSOLUTE_VALUE, + isAbsoluteValue: e.target.checked, + }) + } /> Abs. @@ -223,13 +261,14 @@ export function ColorMapControls(props: ColorMapControlsProps) { {sliderAttributes && ( { + vmin: number; + vmax: number; /** The URL to the color map image */ cmapImage?: string; + cmapRange: number; /** The min, max, and step values for the range slider, determined by histogram's edges and the user's min and max setting */ sliderAttributes: { min: number; max: number; step: number }; + onCmapValuesChange: (vals: number[]) => void; } const regexToFindPercents = /\b\d+(\.\d+)?%/g; @@ -36,7 +35,8 @@ export function ColorMapSlider(props: ColorMapSliderProps) { units, sliderAttributes, quantity, - values, + vmin, + vmax, cmapRange, isLogScale, isAbsoluteValue, @@ -45,7 +45,7 @@ export function ColorMapSlider(props: ColorMapSliderProps) { * Create temporary values for range slider min/max to maintain component state without setting the global state; * the RangeSlider has an onFinalChange handler that will set the global state once a user releases the slider handle */ - const [tempValues, setTempValues] = useState([values[0], values[1]]); + const [tempValues, setTempValues] = useState([vmin, vmax]); const prevKeyUpHandler = useRef<(e: KeyboardEvent) => void>(null); const prevKeyDownHandler = useRef<(e: KeyboardEvent) => void>(null); @@ -89,23 +89,23 @@ export function ColorMapSlider(props: ColorMapSliderProps) { vminRef.current?.setCustomValidity(''); vmaxRef.current?.setCustomValidity(''); - const vmin = parseFloat(vminRef.current?.value ?? ''); - const vmax = parseFloat(vmaxRef.current?.value ?? ''); + const vminToValidate = parseFloat(vminRef.current?.value ?? ''); + const vmaxToValidate = parseFloat(vmaxRef.current?.value ?? ''); - if (!Number.isNaN(vmin) && !Number.isNaN(vmax)) { - if (vmin >= vmax) { + if (!Number.isNaN(vminToValidate) && !Number.isNaN(vmaxToValidate)) { + if (vminToValidate >= vmaxToValidate) { const msg = 'vmax must be greater than vmin'; vmaxRef.current?.setCustomValidity(msg); } // Ensure vmin and vmax are valid for isLogScale if (isLogScale) { - if (vmin <= 0) { + if (vminToValidate <= 0) { const msg = 'vmin must be greater than 0'; vminRef.current?.setCustomValidity(msg); } - if (vmax <= 0) { + if (vmaxToValidate <= 0) { const msg = 'vmax must be greater than 0'; vmaxRef.current?.setCustomValidity(msg); } @@ -114,12 +114,12 @@ export function ColorMapSlider(props: ColorMapSliderProps) { // isLogScale validation is generally the same as isAbsValue // except that 0 is valid if only isAbsValue is true if (!isLogScale && isAbsoluteValue) { - if (vmin < 0) { + if (vminToValidate < 0) { const msg = 'vmin must be greater than or equal to 0'; vminRef.current?.setCustomValidity(msg); } - if (vmax < 0) { + if (vmaxToValidate < 0) { const msg = 'vmax must be greater than or equal to 0'; vmaxRef.current?.setCustomValidity(msg); } @@ -136,13 +136,13 @@ export function ColorMapSlider(props: ColorMapSliderProps) { const vmaxStr = String(formData.get('vmax')); if (vminStr.length && vmaxStr.length) { - const vmin = parseFloat(vminStr); - const vmax = parseFloat(vmaxStr); + const vminFloat = parseFloat(vminStr); + const vmaxFloat = parseFloat(vmaxStr); if (isLogScale) { - onCmapValuesChange([safeLog(vmin), safeLog(vmax)]); + onCmapValuesChange([safeLog(vminFloat), safeLog(vmaxFloat)]); } else { - onCmapValuesChange([vmin, vmax]); + onCmapValuesChange([vminFloat, vmaxFloat]); } setShowVminVmaxInputs(false); (e.target as HTMLFormElement).reset(); @@ -240,8 +240,8 @@ export function ColorMapSlider(props: ColorMapSliderProps) { /** Sync the temp values */ useEffect(() => { - setTempValues([values[0], values[1]]); - }, [values]); + setTempValues([vmin, vmax]); + }, [vmin, vmax]); /** * The getTrackBackground react-range function returns a string with a CSS gradient that diff --git a/src/components/CustomColorMapDialog.tsx b/src/components/CustomColorMapDialog.tsx index 89938d6..e65568e 100644 --- a/src/components/CustomColorMapDialog.tsx +++ b/src/components/CustomColorMapDialog.tsx @@ -1,8 +1,15 @@ import { useCallback, useEffect, useState } from 'react'; -import { ColorMapControlsProps } from './ColorMapControls'; +import { ColorMapConfigChangeAction } from './ColorMapControls'; import './styles/color-map-dialog.css'; import { Dialog } from './Dialog'; import { safeLog } from '../utils/numberUtils'; +import { InternalBaselayer } from '../types/layers'; +import { + CHANGE_LOG_SCALE, + CHANGE_ABSOLUTE_VALUE, + CHANGE_CMAP_TYPE, + CHANGE_CMAP_VALUES, +} from '../reducers/baselayersReducer'; /** * TODOS/QUESTIONS: @@ -10,7 +17,12 @@ import { safeLog } from '../utils/numberUtils'; * 2. Should we also persist a user's parameters? */ -interface Props extends Omit { +interface Props + extends Pick< + InternalBaselayer, + 'cmap' | 'units' | 'isLogScale' | 'isAbsoluteValue' + > { + values: [number, number]; /** Boolean to control dialog display/hide status */ isOpen: boolean; /** Handler to set modal to be closed */ @@ -19,6 +31,7 @@ interface Props extends Omit { setCmapOptions: (options: string[]) => void; /** The list of color map options used to determine whether or not to append a new color map option */ cmapOptions: string[]; + onColorMapConfigChange: (action: ColorMapConfigChangeAction) => void; } export function CustomColorMapDialog({ @@ -26,15 +39,12 @@ export function CustomColorMapDialog({ closeModal, values, cmap, - onCmapChange, - onCmapValuesChange, cmapOptions, setCmapOptions, units, isLogScale, - onLogScaleChange, isAbsoluteValue, - onAbsoluteValueChange, + onColorMapConfigChange, }: Props) { // Create temporary values to maintain component state without setting the global state, which is only done during "Update Map" const [tempCmap, setTempCmap] = useState(cmap); @@ -71,26 +81,42 @@ export function CustomColorMapDialog({ // For each input, only fire the update handler if the value changed if (tempCmap !== cmap) { - onCmapChange(tempCmap); + onColorMapConfigChange({ type: CHANGE_CMAP_TYPE, cmap: tempCmap }); } if ( Number(tempValues[0]) !== values[0] || Number(tempValues[1]) !== values[1] ) { - onCmapValuesChange( - tempValues.map((v) => - v ? (isLogScale ? safeLog(Number(v)) : Number(v)) : 0 - ) - ); + const safeVmin = tempValues[0] + ? isLogScale + ? safeLog(Number(tempValues[0])) + : Number(tempValues[0]) + : 0; + const safeVmax = tempValues[1] + ? isLogScale + ? safeLog(Number(tempValues[1])) + : Number(tempValues[1]) + : 0; + onColorMapConfigChange({ + type: CHANGE_CMAP_VALUES, + vmin: safeVmin, + vmax: safeVmax, + }); } if (tempIsLogScale !== isLogScale) { - onLogScaleChange(tempIsLogScale); + onColorMapConfigChange({ + type: CHANGE_LOG_SCALE, + isLogScale: tempIsLogScale, + }); } if (tempIsAbsValue !== isAbsoluteValue) { - onAbsoluteValueChange(tempIsAbsValue); + onColorMapConfigChange({ + type: CHANGE_ABSOLUTE_VALUE, + isAbsoluteValue: tempIsAbsValue, + }); } // Check if tempCmap exists in cmapOptions and concat as a new option if not @@ -99,9 +125,8 @@ export function CustomColorMapDialog({ } closeModal(); }, [ - onCmapChange, + onColorMapConfigChange, tempCmap, - onCmapValuesChange, tempValues, closeModal, cmapOptions, @@ -110,10 +135,8 @@ export function CustomColorMapDialog({ tempIsLogScale, cmap, values, - onLogScaleChange, tempIsAbsValue, isAbsoluteValue, - onAbsoluteValueChange, ]); return ( diff --git a/src/components/BaselayerSections.tsx b/src/components/ExternalBaselayersSection.tsx similarity index 69% rename from src/components/BaselayerSections.tsx rename to src/components/ExternalBaselayersSection.tsx index f1929bd..d484958 100644 --- a/src/components/BaselayerSections.tsx +++ b/src/components/ExternalBaselayersSection.tsx @@ -1,17 +1,15 @@ import { useState, ReactNode, useCallback, memo } from 'react'; import { LayerSelectorProps, NoMatches } from './LayerSelector'; -import CollapsibleSection from './CollapsibleSection'; import { EXTERNAL_BASELAYERS, EXTERNAL_DETAILS_ID, } from '../configs/mapSettings'; -import { getDefaultExpandedState, filterMapGroups } from '../utils/filterUtils'; import { ChevronRightIcon } from './icons/ChevronRightIcon'; import { ChevronDownIcon } from './icons/ChevronDownIcon'; -type BaselayerSectionsProps = { - mapGroups: LayerSelectorProps['mapGroups']; - activeBaselayerId: LayerSelectorProps['activeBaselayerId']; +type ExternalBaselayersSectionProps = { + internalSearchLength: number | undefined; + activeBaselayerId: LayerSelectorProps['selectedBaselayerId']; isFlipped: LayerSelectorProps['isFlipped']; onBaselayerChange: LayerSelectorProps['onBaselayerChange']; searchText: string; @@ -21,26 +19,20 @@ type BaselayerSectionsProps = { ) => string | ReactNode; }; -function BaselayerSections({ - mapGroups, +function ExternalBaselayersSection({ + internalSearchLength, activeBaselayerId, isFlipped, onBaselayerChange, searchText, markMatchingSearchText, -}: BaselayerSectionsProps) { +}: ExternalBaselayersSectionProps) { const [expandedState, setExpandedState] = useState>( - getDefaultExpandedState(mapGroups, activeBaselayerId) - ); - const { filteredMapGroups, matchedIds } = filterMapGroups( - mapGroups, - searchText + new Set([EXTERNAL_DETAILS_ID]) ); const filteredExternalLayers = EXTERNAL_BASELAYERS.filter((bl) => bl.name.toLowerCase().includes(searchText.toLowerCase()) ); - const isEmpty = - filteredMapGroups.length + filteredExternalLayers.length === 0; const handleToggle = useCallback( (id: string) => { @@ -57,27 +49,12 @@ function BaselayerSections({ [expandedState] ); - if (isEmpty) { + if (internalSearchLength === 0 && filteredExternalLayers.length === 0) { return ; } return ( <> - {filteredMapGroups.map((group) => ( - - ))} {filteredExternalLayers.length > 0 && (
onBaselayerChange(bl.layer_id, 'layerMenu')} + onChange={() => + onBaselayerChange( + bl.layer_id, + 'layerMenu', + undefined, + undefined + ) + } disabled={bl.disabledState(isFlipped)} />