From e9f09faae257994dbb83ccd038f23410cc0cc518 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 23 Apr 2026 11:46:21 -0400 Subject: [PATCH 01/10] Refactor initial state and baselayer reducer state --- src/App.tsx | 30 ++++---- src/reducers/baselayersReducer.ts | 86 ++++++++++------------ src/types/maps.ts | 2 +- src/utils/fetchUtils.ts | 118 ++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 65 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index eb44958..34a5774 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { MapGroupResponse, SourceGroup, Box } from './types/maps'; import { ColorMapControls } from './components/ColorMapControls'; import { fetchBoxes, - fetchMaps, + fetchInitialState, fetchSources, getHistogramData, } from './utils/fetchUtils'; @@ -43,41 +43,37 @@ function App() { 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 { mapGroups, internalBaselayers } = await fetchMaps(); + const { mapGroups, defaultLayer } = await fetchInitialState(); - if (!mapGroups.length || !internalBaselayers.length) { + if (!mapGroups.length || !defaultLayer) { // If we end up with no maps, 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 defaultInitialBaselayer = { ...internalBaselayers[0] }; + const histogramData = await 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; + const histogramData = await getHistogramData(defaultLayer.layer_id); + 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 dispatchBaselayersChange({ type: SET_BASELAYERS_STATE, - internalBaselayers: internalBaselayers, + defaultInternalBaselayer: defaultLayer, histogramData, }); } diff --git a/src/reducers/baselayersReducer.ts b/src/reducers/baselayersReducer.ts index 9897a93..bde18b9 100644 --- a/src/reducers/baselayersReducer.ts +++ b/src/reducers/baselayersReducer.ts @@ -65,7 +65,7 @@ type ChangeBaselayerAction = { type SetBaselayersAction = { type: typeof SET_BASELAYERS_STATE; - internalBaselayers: InternalBaselayer[]; + defaultInternalBaselayer: InternalBaselayer | undefined; histogramData: HistogramResponse | undefined; }; @@ -80,14 +80,24 @@ export type Action = export function baselayersReducer(state: BaselayersState, action: Action) { switch (action.type) { case 'SET_BASELAYERS_STATE': { + const internalBaselayers = new Map(); + const hasDefaultBaselayer = assertInternalBaselayer( + action.defaultInternalBaselayer + ); + + if (hasDefaultBaselayer) { + internalBaselayers.set( + action.defaultInternalBaselayer!.layer_id, + action.defaultInternalBaselayer + ); + } return { - internalBaselayers: action.internalBaselayers, + internalBaselayers, // If no internalBaselayers are returned from server request, set activeBaselayer to be first external baselayer; note // that the histogramData in this scenario will be set to undefined - activeBaselayer: - action.internalBaselayers.length === 0 - ? EXTERNAL_BASELAYERS[0] - : action.internalBaselayers[0], + activeBaselayer: hasDefaultBaselayer + ? action.defaultInternalBaselayer + : EXTERNAL_BASELAYERS[0], histogramData: action.histogramData, }; } @@ -99,16 +109,10 @@ export function baselayersReducer(state: BaselayersState, action: Action) { }; return { ...state, - internalBaselayers: state.internalBaselayers?.map((layer) => { - if ( - layer.layer_id === - (action.activeBaselayer as InternalBaselayer).layer_id - ) { - return updatedActiveBaselayer; - } else { - return layer; - } - }), + internalBaselayers: state.internalBaselayers?.set( + action.activeBaselayer.layer_id, + updatedActiveBaselayer + ), activeBaselayer: updatedActiveBaselayer, }; } else { @@ -150,16 +154,10 @@ export function baselayersReducer(state: BaselayersState, action: Action) { return { ...state, - internalBaselayers: state.internalBaselayers?.map((layer) => { - if ( - layer.layer_id === - (action.activeBaselayer as InternalBaselayer).layer_id - ) { - return updatedActiveBaselayer; - } else { - return layer; - } - }), + internalBaselayers: state.internalBaselayers?.set( + action.activeBaselayer.layer_id, + updatedActiveBaselayer + ), activeBaselayer: updatedActiveBaselayer, }; } else { @@ -195,16 +193,10 @@ export function baselayersReducer(state: BaselayersState, action: Action) { return { ...state, - internalBaselayers: state.internalBaselayers?.map((layer) => { - if ( - layer.layer_id === - (action.activeBaselayer as InternalBaselayer).layer_id - ) { - return updatedActiveBaselayer; - } else { - return layer; - } - }), + internalBaselayers: state.internalBaselayers?.set( + action.activeBaselayer.layer_id, + updatedActiveBaselayer + ), activeBaselayer: updatedActiveBaselayer, }; } else { @@ -222,16 +214,10 @@ export function baselayersReducer(state: BaselayersState, action: Action) { }; return { ...state, - internalBaselayers: state.internalBaselayers?.map((layer) => { - if ( - layer.layer_id === - (action.activeBaselayer as InternalBaselayer).layer_id - ) { - return updatedActiveBaselayer; - } else { - return layer; - } - }), + internalBaselayers: state.internalBaselayers?.set( + action.activeBaselayer.layer_id, + updatedActiveBaselayer + ), activeBaselayer: updatedActiveBaselayer, }; } else { @@ -243,9 +229,15 @@ export function baselayersReducer(state: BaselayersState, action: Action) { case 'CHANGE_BASELAYER': { const { newBaselayer, histogramData } = action; + const isNewInternalBaselayer = + assertInternalBaselayer(newBaselayer) && + !state.internalBaselayers?.has(newBaselayer.layer_id); + if (histogramData) { return { - ...state, + internalBaselayers: isNewInternalBaselayer + ? state.internalBaselayers?.set(newBaselayer.layer_id, newBaselayer) + : state.internalBaselayers, histogramData, activeBaselayer: newBaselayer, }; diff --git a/src/types/maps.ts b/src/types/maps.ts index c784c01..30e0d95 100644 --- a/src/types/maps.ts +++ b/src/types/maps.ts @@ -138,7 +138,7 @@ export type BaselayersState = { /** the active baselayer selected in the map's legend */ activeBaselayer?: InternalBaselayer | ExternalBaselayer; /** the internal SO layers used as baselayers */ - internalBaselayers?: InternalBaselayer[]; + internalBaselayers?: Map; /** the active baselayer's histogram data */ histogramData?: HistogramResponse; }; diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index df5fdca..a43b986 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -11,6 +11,124 @@ import { } from '../types/maps'; import { SubmapFileExtensions } from '../configs/submapConfigs'; +const cachedLayerIds = new Set(); + +export async function fetchInitialState() { + // Get the default layer + const defaultLayerData = await ( + await fetch(`${SERVICE_URL}/layers/default`) + ).json(); + + cachedLayerIds.add(defaultLayerData.layer.layer_id); + + // Get map group summaries for layer menu + const mapGroupSummaries = await ( + await fetch(`${SERVICE_URL}/map-groups`) + ).json(); + + const mapSummaries = await ( + await fetch( + `${SERVICE_URL}/map-groups/${defaultLayerData.map_group_id}/maps` + ) + ).json(); + + const bandSummaries = await ( + await fetch(`${SERVICE_URL}/maps/${defaultLayerData.map_id}/bands`) + ).json(); + + const layerSummaries = await ( + await fetch(`${SERVICE_URL}/bands/${defaultLayerData.band_id}/layers`) + ).json(); + + const mapGroups = mapGroupSummaries.map((mapGroup) => { + if (mapGroup.map_group_id === defaultLayerData.map_group_id) { + return { + ...mapGroup, + maps: mapSummaries.map((map) => { + if (map.map_id === defaultLayerData.map_id) { + return { + ...map, + bands: bandSummaries.map((band) => { + if (band.band_id === defaultLayerData.band_id) { + return { + ...band, + layers: layerSummaries, + }; + } else { + return { + ...band, + layers: [], + }; + } + }), + }; + } else { + return { + ...map, + bands: [], + }; + } + }), + }; + } else { + return { + ...mapGroup, + maps: [], + }; + } + }); + + // Set to undefined if 'auto' so we can know to set this value + // with the layer's histogram response instead + const vmin = + defaultLayerData.layer.vmin === 'auto' + ? undefined + : defaultLayerData.layer.vmin; + const vmax = + defaultLayerData.layer.vmax === 'auto' + ? undefined + : defaultLayerData.layer.vmax; + + const defaultLayer = { + ...defaultLayerData.layer, + isLogScale: false, + isAbsoluteValue: false, + vmin, + vmax, + }; + + return { defaultLayer, mapGroupSummaries, mapGroups }; +} + +export async function fetchLayer( + layerId: string, + internalBaselayers: Map | undefined +) { + const isCached = cachedLayerIds.has(layerId); + if (isCached) { + return internalBaselayers?.get(layerId); + } else { + const newLayerData = await ( + await fetch(`${SERVICE_URL}/layers/${layerId}`) + ).json(); + + // 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; + } +} + export async function fetchMaps() { // Get the list of map groups and unpack the response const mapGroups: MapGroupResponse[] = await ( From 7b4369769b2992a5c71b0108e0b72174cadf5a9e Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Thu, 23 Apr 2026 11:46:47 -0400 Subject: [PATCH 02/10] Refactor baselayer creation and management --- src/components/OpenLayersMap.tsx | 99 +++++--------------------------- src/hooks/useBaselayerChange.ts | 40 +++++++------ src/hooks/useLayerRegistry.ts | 91 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 104 deletions(-) create mode 100644 src/hooks/useLayerRegistry.ts diff --git a/src/components/OpenLayersMap.tsx b/src/components/OpenLayersMap.tsx index 32b482b..464afac 100644 --- a/src/components/OpenLayersMap.tsx +++ b/src/components/OpenLayersMap.tsx @@ -1,15 +1,5 @@ -import { - ChangeEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { Map, View, Feature, MapBrowserEvent } from 'ol'; -import { TileGrid } from 'ol/tilegrid'; -import { Tile as TileLayer } from 'ol/layer'; -import { XYZ } from 'ol/source'; import { Overlay } from 'ol'; import ScaleLine from 'ol/control/ScaleLine.js'; import VectorLayer from 'ol/layer/Vector'; @@ -26,7 +16,6 @@ import { } from '../types/maps'; import { DEFAULT_INTERNAL_MAP_SETTINGS, - EXTERNAL_BASELAYERS, SERVICE_URL, } from '../configs/mapSettings'; import { CoordinatesDisplay } from './CoordinatesDisplay'; @@ -42,16 +31,13 @@ import { } from '../utils/externalSearchUtils'; import './styles/highlight-box.css'; import { assertInternalBaselayer } from '../reducers/baselayersReducer'; -import { - getBaselayerResolutions, - transformCoords, - transformGraticuleCoords, -} from '../utils/layerUtils'; +import { transformCoords, transformGraticuleCoords } from '../utils/layerUtils'; import { ToggleSwitch } from './ToggleSwitch'; import { CenterMapFeature } from './CenterMapFeature'; import { AperturesLayer } from './layers/AperturesLayer'; import { LoadingOverlay } from './LoadingOverlay'; import { useTileLoading } from '../hooks/useTileLoading'; +import { useLayerRegistry } from '../hooks/useLayerRegistry'; export type MapProps = { mapGroups: MapGroupResponse[]; @@ -113,55 +99,9 @@ export function OpenLayersMap({ const isLoadingTiles = useTileLoading(mapRef); - const { activeBaselayer, internalBaselayers } = baselayersState; + const { activeBaselayer } = baselayersState; - const tileLayers = useMemo(() => { - return internalBaselayers?.map( - (layer) => - new TileLayer({ - properties: { id: 'baselayer-' + layer.layer_id }, - source: new XYZ({ - url: `${SERVICE_URL}/maps/${layer.layer_id}/{z}/{-y}/{x}/tile.png?cmap=${layer.cmap}&vmin=${layer.isLogScale ? Math.pow(10, layer.vmin!) : layer.vmin}&vmax=${layer.isLogScale ? Math.pow(10, layer.vmax!) : layer.vmax}&flip=${flipTiles}&log_norm=${layer.isLogScale}&abs=${layer.isAbsoluteValue}`, - tileGrid: new TileGrid({ - extent: [-180, -90, 180, 90], - origin: [-180, 90], - tileSize: layer.tile_size, - resolutions: getBaselayerResolutions( - 180, - layer.tile_size, - layer.number_of_levels - 1 - ), - }), - interpolate: false, - projection: 'EPSG:4326', - tilePixelRatio: layer.tile_size / 256, - }), - }) - ); - }, [internalBaselayers, flipTiles]); - - const externalTileLayers = useMemo(() => { - return EXTERNAL_BASELAYERS.map((b) => { - return new TileLayer({ - properties: { id: b.layer_id }, - source: new XYZ({ - url: typeof b.url === 'string' ? b.url : undefined, - tileUrlFunction: typeof b.url !== 'string' ? b.url : undefined, - projection: b.projection, - tileGrid: new TileGrid({ - extent: b.extent, - resolutions: getBaselayerResolutions( - b.extent[2] - b.extent[0], - 256, - b.maxZoom - ), - origin: [b.extent[0], b.extent[3]], - }), - wrapX: true, - }), - }); - }); - }, []); + const { getOrCreateLayer } = useLayerRegistry(); /** * Create the map with a scale control, a layer for the "add box" functionality @@ -320,29 +260,18 @@ export function OpenLayersMap({ mapRef.current?.removeLayer(layer); } }); - if (assertInternalBaselayer(activeBaselayer)) { - const activeLayer = tileLayers!.find( - (t) => t.get('id') === 'baselayer-' + activeBaselayer!.layer_id - )!; - const activeLayerSource = activeLayer.getSource(); - activeLayerSource?.setUrl( - `${SERVICE_URL}/maps/${activeBaselayer.layer_id}/{z}/{-y}/{x}/tile.png?cmap=${activeBaselayer.cmap}&vmin=${activeBaselayer.isLogScale ? Math.pow(10, activeBaselayer.vmin!) : activeBaselayer.vmin}&vmax=${activeBaselayer.isLogScale ? Math.pow(10, activeBaselayer.vmax!) : activeBaselayer.vmax}&flip=${flipTiles}&log_norm=${activeBaselayer.isLogScale}&abs=${activeBaselayer.isAbsoluteValue}` - ); - mapRef.current.addLayer(activeLayer); - } else { - const externalBaselayer = EXTERNAL_BASELAYERS.find( - (b) => b.layer_id === activeBaselayer.layer_id - ); - const activeLayer = externalTileLayers.find( - (t) => t.get('id') === activeBaselayer.layer_id - )!; - if (!externalBaselayer || !activeLayer) return; + const isInternal = assertInternalBaselayer(activeBaselayer); - mapRef.current.addLayer(activeLayer); - } + const activeLayer = getOrCreateLayer( + activeBaselayer, + isInternal + ? `${SERVICE_URL}/layers/${activeBaselayer.layer_id}/{z}/{-y}/{x}/tile.png?cmap=${activeBaselayer.cmap}&vmin=${activeBaselayer.isLogScale ? Math.pow(10, activeBaselayer.vmin!) : activeBaselayer.vmin}&vmax=${activeBaselayer.isLogScale ? Math.pow(10, activeBaselayer.vmax!) : activeBaselayer.vmax}&flip=${flipTiles}&log_norm=${activeBaselayer.isLogScale}&abs=${activeBaselayer.isAbsoluteValue}` + : undefined + ); + mapRef.current.addLayer(activeLayer); } - }, [activeBaselayer, tileLayers, externalTileLayers, flipTiles]); + }, [activeBaselayer, flipTiles]); /** * Add keyboard support for switching baselayers diff --git a/src/hooks/useBaselayerChange.ts b/src/hooks/useBaselayerChange.ts index 53748a8..a906323 100644 --- a/src/hooks/useBaselayerChange.ts +++ b/src/hooks/useBaselayerChange.ts @@ -1,16 +1,12 @@ import { useOptimistic, useState, useTransition, useCallback } from 'react'; -import { - BaselayersState, - InternalBaselayer, - ExternalBaselayer, -} from '../types/maps'; +import { BaselayersState } from '../types/maps'; import { Action, assertInternalBaselayer, CHANGE_BASELAYER, } from '../reducers/baselayersReducer'; import { EXTERNAL_BASELAYERS } from '../configs/mapSettings'; -import { getHistogramData } from '../utils/fetchUtils'; +import { fetchLayer, getHistogramData } from '../utils/fetchUtils'; export function useBaselayerChange( baselayersState: BaselayersState, @@ -40,33 +36,23 @@ export function useBaselayerChange( ) => { if (selectedBaselayerId === optimisticBaselayerId) return; - const isExternal = selectedBaselayerId.includes('external'); - const newActiveBaselayer: - | ExternalBaselayer - | InternalBaselayer - | undefined = isExternal - ? EXTERNAL_BASELAYERS.find((b) => b.layer_id === selectedBaselayerId) - : internalBaselayers?.find((b) => b.layer_id === selectedBaselayerId); - - if (!newActiveBaselayer) return; - // Update history stacks synchronously before the transition if (context === 'goBack') { setBackHistoryStack((prev) => prev.slice(0, -1)); setForwardHistoryStack((prev) => [ ...prev, - { id: String(activeBaselayer?.layer_id), flipped: flipTiles }, + { id: String(selectedBaselayerId), flipped: flipTiles }, ]); } else if (context === 'goForward') { setBackHistoryStack((prev) => [ ...prev, - { id: String(activeBaselayer?.layer_id), flipped: flipTiles }, + { id: String(selectedBaselayerId), flipped: flipTiles }, ]); setForwardHistoryStack((prev) => prev.slice(0, -1)); } else { setBackHistoryStack((prev) => [ ...prev, - { id: String(activeBaselayer?.layer_id), flipped: flipTiles }, + { id: String(selectedBaselayerId), flipped: flipTiles }, ]); setForwardHistoryStack([]); } @@ -75,6 +61,22 @@ export function useBaselayerChange( startTransition(async () => { setOptimisticBaselayerId(selectedBaselayerId); // instant UI feedback + let newActiveBaselayer = undefined; + + const isExternal = selectedBaselayerId.includes('external'); + + if (isExternal) { + newActiveBaselayer = EXTERNAL_BASELAYERS.find( + (b) => b.layer_id === selectedBaselayerId + ); + } else { + newActiveBaselayer = await fetchLayer( + selectedBaselayerId, + internalBaselayers + ); + } + + if (!newActiveBaselayer) return; try { if (assertInternalBaselayer(newActiveBaselayer)) { diff --git a/src/hooks/useLayerRegistry.ts b/src/hooks/useLayerRegistry.ts new file mode 100644 index 0000000..68acdb0 --- /dev/null +++ b/src/hooks/useLayerRegistry.ts @@ -0,0 +1,91 @@ +import { useRef, useCallback, useEffect } from 'react'; +import { TileGrid } from 'ol/tilegrid'; +import { Tile as TileLayer } from 'ol/layer'; +import { XYZ } from 'ol/source'; +import { EXTERNAL_BASELAYERS } from '../configs/mapSettings'; +import { getBaselayerResolutions } from '../utils/layerUtils'; +import { ExternalBaselayer, InternalBaselayer } from '../types/maps'; +import { assertInternalBaselayer } from '../reducers/baselayersReducer'; + +export function useLayerRegistry() { + const registry = useRef(new Map()); // layerId -> TileLayer + + // Create and set external baselayers once + useEffect(() => { + EXTERNAL_BASELAYERS.forEach((b) => { + registry.current.set( + b.layer_id, + new TileLayer({ + properties: { id: b.layer_id }, + source: new XYZ({ + url: typeof b.url === 'string' ? b.url : undefined, + tileUrlFunction: typeof b.url !== 'string' ? b.url : undefined, + projection: b.projection, + tileGrid: new TileGrid({ + extent: b.extent, + resolutions: getBaselayerResolutions( + b.extent[2] - b.extent[0], + 256, + b.maxZoom + ), + origin: [b.extent[0], b.extent[3]], + }), + wrapX: true, + }), + }) + ); + }); + }, []); + + const getOrCreateLayer = useCallback( + (layer: InternalBaselayer | ExternalBaselayer, url: string | undefined) => { + const isInternal = assertInternalBaselayer(layer); + + if (registry.current.has(layer.layer_id)) { + const existing = registry.current.get(layer.layer_id); + const source = existing.getSource(); + + if (isInternal) { + source.setUrl(url); + } else { + return existing; + } + + return existing; + } + + if (isInternal) { + // First request for this layer: construct and cache it + const newLayer = new TileLayer({ + properties: { id: 'baselayer-' + layer.layer_id }, + source: new XYZ({ + url, + tileGrid: new TileGrid({ + extent: [-180, -90, 180, 90], + origin: [-180, 90], + tileSize: layer.tile_size, + resolutions: getBaselayerResolutions( + 180, + layer.tile_size, + layer.number_of_levels - 1 + ), + }), + interpolate: false, + projection: 'EPSG:4326', + tilePixelRatio: layer.tile_size / 256, + }), + }); + + registry.current.set(layer.layer_id, newLayer); + return newLayer; + } + }, + [] + ); + + const getLayer = useCallback((layerId: string) => { + return registry.current.get(layerId) ?? null; + }, []); + + return { getOrCreateLayer, getLayer, registry }; +} From 09b57f56976569c61466f5c3c29e1252de4488d9 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Fri, 24 Apr 2026 10:46:39 -0400 Subject: [PATCH 03/10] Split types file into separate files --- src/App.tsx | 19 +- src/components/BoxMenu.tsx | 2 +- src/components/ColorMapControls.tsx | 2 +- src/components/ColorMapHistogram.tsx | 2 +- src/components/OpenLayersMap.tsx | 17 +- .../layers/AddHighlightBoxLayer.tsx | 2 +- src/components/layers/HighlightBoxLayer.tsx | 2 +- src/components/layers/SourcesLayer.tsx | 2 +- src/configs/mapSettings.ts | 2 +- src/hooks/useBaselayerChange.ts | 2 +- src/reducers/baselayersReducer.ts | 2 +- src/types/histogram.ts | 9 + src/types/layers.ts | 99 ++++++++++ src/types/maps.ts | 170 ------------------ src/types/sources.ts | 28 +++ src/types/submaps.ts | 41 +++++ src/utils/fetchUtils.ts | 114 +----------- src/utils/filterUtils.ts | 22 +-- src/utils/layerUtils.ts | 3 +- 19 files changed, 226 insertions(+), 314 deletions(-) create mode 100644 src/types/histogram.ts create mode 100644 src/types/layers.ts delete mode 100644 src/types/maps.ts create mode 100644 src/types/sources.ts create mode 100644 src/types/submaps.ts diff --git a/src/App.tsx b/src/App.tsx index 34a5774..434ee30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useCallback, useMemo, useState, useReducer, ChangeEvent } from 'react'; -import { MapGroupResponse, SourceGroup, Box } from './types/maps'; +import { DefaultLayer, MapGroup } from './types/layers'; +import { SourceGroup } from './types/sources'; +import { Box } from './types/submaps'; import { ColorMapControls } from './components/ColorMapControls'; import { fetchBoxes, @@ -35,18 +37,17 @@ function App() { 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 + const { data: defaultLayerData, isLoading: areMapGroupsLoading } = useQuery< + { defaultLayer: DefaultLayer; defaultMenuState: MapGroup[] } | 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 { mapGroups, defaultLayer } = await fetchInitialState(); + const { defaultMenuState, defaultLayer } = await fetchInitialState(); - if (!mapGroups.length || !defaultLayer) { + if (!defaultLayer) { // If we end up with no maps, SET_BASELAYERS_STATE will fall back to an external baselayer as its default initial baselayer dispatchBaselayersChange({ type: SET_BASELAYERS_STATE, @@ -78,7 +79,7 @@ function App() { }); } - return mapGroups; + return { defaultMenuState, defaultLayer }; }, }); @@ -245,9 +246,9 @@ function App() { {isAuthenticated !== null && activeBaselayer && internalBaselayers && - mapGroups && ( + defaultLayerData?.defaultMenuState && ( ; diff --git a/src/types/layers.ts b/src/types/layers.ts new file mode 100644 index 0000000..54c9e4f --- /dev/null +++ b/src/types/layers.ts @@ -0,0 +1,99 @@ +import { HistogramResponse } from './histogram'; + +export type MapGroupSummary = { + map_group_id: string; + name: string; + description: string; +}; + +export type MapSummary = { + map_id: string; + name: string; + description: string; +}; + +export type BandSummary = { + band_id: string; + name: string; + description: string; +}; + +export type LayerSummary = { + layer_id: string; + name: string; + description: string; +}; + +export type MapGroup = MapGroupSummary & { + maps: (MapSummary & { + bands: (BandSummary & { layers: LayerSummary[] })[]; + })[]; +}; + +export type LayerResponse = LayerSummary & { + provider: { + provider_name: string; + filename: string; + hdu: number; + index: number; + }; + bounding_left: number; + bounding_right: number; + bounding_top: number; + bounding_bottom: number; + quantity: string; + units: string; + number_of_levels: number; + tile_size: number; + /** layers' vmin/vmax are either predefined or set to 'auto' */ + vmin: number | 'auto'; + vmax: number | 'auto'; + cmap: string; +}; + +export type DefaultLayer = LayerResponse & { + map_group_id: string; + map_id: string; + band_id: string; +}; + +type EnhancedLayerAttributes = { + mapId: string; + bandId: string; + isLogScale: boolean; + isAbsoluteValue: boolean; +}; + +export type InternalBaselayer = Omit & { + /** After processing layer response, 'auto' gets set to undefined and later + * set to a value from the layer's histogram response + */ + vmin: undefined | number; + vmax: undefined | number; +} & EnhancedLayerAttributes; + +export type GraticuleDetails = { + pixelWidth: number; + interval: number; +}; + +type TileUrlFunction = (x: number[]) => string; + +export type ExternalBaselayer = { + layer_id: string; + name: string; + projection: string; + url: string | TileUrlFunction; + extent: number[]; + maxZoom: number; + disabledState: (state: boolean) => boolean; +}; + +export type BaselayersState = { + /** the active baselayer selected in the map's legend */ + activeBaselayer?: InternalBaselayer | ExternalBaselayer; + /** the internal SO layers used as baselayers */ + internalBaselayers?: Map; + /** the active baselayer's histogram data */ + histogramData?: HistogramResponse; +}; diff --git a/src/types/maps.ts b/src/types/maps.ts deleted file mode 100644 index 30e0d95..0000000 --- a/src/types/maps.ts +++ /dev/null @@ -1,170 +0,0 @@ -export type MapGroupResponse = { - grant: string; - name: string; - description: string; - maps: MapResponse[]; -}; - -export type MapResponse = { - grant: string; - map_id: string; - name: string; - description: string; - bands: BandResponse[]; -}; - -export type BandResponse = { - grant: string; - band_id: string; - name: string; - description: string; - layers: LayerResponse[]; -}; - -export type LayerResponse = { - grant: string; - layer_id: string; - name: string; - description: string; - provider: { - provider_name: string; - filename: string; - hdu: number; - index: number; - }; - bounding_left: number; - bounding_right: number; - bounding_top: number; - bounding_bottom: number; - quantity: string; - units: string; - number_of_levels: number; - tile_size: number; - /** layers' vmin/vmax are either predefined or set to 'auto' */ - vmin: number | 'auto'; - vmax: number | 'auto'; - cmap: string; -}; - -type EnhancedLayerAttributes = { - mapId: string; - bandId: string; - isLogScale: boolean; - isAbsoluteValue: boolean; -}; - -export type InternalBaselayer = Omit & { - /** After processing layer response, 'auto' gets set to undefined and later - * set to a value from the layer's histogram response - */ - vmin: undefined | number; - vmax: undefined | number; -} & EnhancedLayerAttributes; - -export type HistogramResponse = { - edges: number[]; - histogram: number[]; - band_id: number; - vmin: number; - vmax: number; -}; - -export type HistogramData = Omit; - -export type GraticuleDetails = { - pixelWidth: number; - interval: number; -}; - -export type SourceGroupResponse = { - /** id of the source catalog */ - source_group_id: string; - /** name of the source group */ - name: string; - /** optional description attribute */ - description?: string; -}; - -export type Source = { - /** optional name attribute */ - name?: string; - /** value of right ascension for the source */ - ra: number; - /** value of declination for the source */ - dec: number; - /** additional information about the source */ - extra: Record; -}; - -export type SourceData = Source & { id: string }; - -export interface SourceGroup extends SourceGroupResponse { - /** used to map coloway hex strings to source groups */ - clientId: number; - /** the list of sources associated with a source catalog */ - sources: Source[]; -} - -export type BoxExtent = { - top_left_ra: number; - top_left_dec: number; - bottom_right_ra: number; - bottom_right_dec: number; -}; - -export type BoxResponse = BoxExtent & { - grant: string; - name: string; - description: string; -}; - -export type Box = BoxResponse & { - id: number; -}; - -type TileUrlFunction = (x: number[]) => string; - -export type ExternalBaselayer = { - layer_id: string; - name: string; - projection: string; - url: string | TileUrlFunction; - extent: number[]; - maxZoom: number; - disabledState: (state: boolean) => boolean; -}; - -export type BaselayersState = { - /** the active baselayer selected in the map's legend */ - activeBaselayer?: InternalBaselayer | ExternalBaselayer; - /** the internal SO layers used as baselayers */ - internalBaselayers?: Map; - /** the active baselayer's histogram data */ - histogramData?: HistogramResponse; -}; - -export type SubmapData = { - layer_id: string; - vmin: number | undefined; - vmax: number | undefined; - cmap: string | undefined; - isLogScale: boolean; - isAbsoluteValue: boolean; -}; - -export type SubmapDataWithBounds = SubmapData & { - top: number; - left: number; - bottom: number; - right: number; -}; - -export type BoxWithDimensions = Box & { - width: number; - height: number; -}; - -export type NewBoxData = Omit & { - width: number; - height: number; -}; diff --git a/src/types/sources.ts b/src/types/sources.ts new file mode 100644 index 0000000..e4fa725 --- /dev/null +++ b/src/types/sources.ts @@ -0,0 +1,28 @@ +export type SourceGroupResponse = { + /** id of the source catalog */ + source_group_id: string; + /** name of the source group */ + name: string; + /** optional description attribute */ + description?: string; +}; + +export type Source = { + /** optional name attribute */ + name?: string; + /** value of right ascension for the source */ + ra: number; + /** value of declination for the source */ + dec: number; + /** additional information about the source */ + extra: Record; +}; + +export type SourceData = Source & { id: string }; + +export interface SourceGroup extends SourceGroupResponse { + /** used to map coloway hex strings to source groups */ + clientId: number; + /** the list of sources associated with a source catalog */ + sources: Source[]; +} diff --git a/src/types/submaps.ts b/src/types/submaps.ts new file mode 100644 index 0000000..df802c8 --- /dev/null +++ b/src/types/submaps.ts @@ -0,0 +1,41 @@ +export type BoxExtent = { + top_left_ra: number; + top_left_dec: number; + bottom_right_ra: number; + bottom_right_dec: number; +}; + +export type BoxResponse = BoxExtent & { + name: string; + description: string; +}; + +export type Box = BoxResponse & { + id: number; +}; + +export type SubmapData = { + layer_id: string; + vmin: number | undefined; + vmax: number | undefined; + cmap: string | undefined; + isLogScale: boolean; + isAbsoluteValue: boolean; +}; + +export type SubmapDataWithBounds = SubmapData & { + top: number; + left: number; + bottom: number; + right: number; +}; + +export type BoxWithDimensions = Box & { + width: number; + height: number; +}; + +export type NewBoxData = Omit & { + width: number; + height: number; +}; diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index a43b986..194720e 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -1,14 +1,8 @@ import { SERVICE_URL } from '../configs/mapSettings'; -import { - Box, - BoxResponse, - InternalBaselayer, - MapGroupResponse, - SourceGroup, - SourceGroupResponse, - SubmapDataWithBounds, - HistogramResponse, -} from '../types/maps'; +import { InternalBaselayer } from '../types/layers'; +import { Box, BoxResponse, SubmapDataWithBounds } from '../types/submaps'; +import { SourceGroup, SourceGroupResponse } from '../types/sources'; +import { HistogramResponse } from '../types/histogram'; import { SubmapFileExtensions } from '../configs/submapConfigs'; const cachedLayerIds = new Set(); @@ -21,63 +15,6 @@ export async function fetchInitialState() { cachedLayerIds.add(defaultLayerData.layer.layer_id); - // Get map group summaries for layer menu - const mapGroupSummaries = await ( - await fetch(`${SERVICE_URL}/map-groups`) - ).json(); - - const mapSummaries = await ( - await fetch( - `${SERVICE_URL}/map-groups/${defaultLayerData.map_group_id}/maps` - ) - ).json(); - - const bandSummaries = await ( - await fetch(`${SERVICE_URL}/maps/${defaultLayerData.map_id}/bands`) - ).json(); - - const layerSummaries = await ( - await fetch(`${SERVICE_URL}/bands/${defaultLayerData.band_id}/layers`) - ).json(); - - const mapGroups = mapGroupSummaries.map((mapGroup) => { - if (mapGroup.map_group_id === defaultLayerData.map_group_id) { - return { - ...mapGroup, - maps: mapSummaries.map((map) => { - if (map.map_id === defaultLayerData.map_id) { - return { - ...map, - bands: bandSummaries.map((band) => { - if (band.band_id === defaultLayerData.band_id) { - return { - ...band, - layers: layerSummaries, - }; - } else { - return { - ...band, - layers: [], - }; - } - }), - }; - } else { - return { - ...map, - bands: [], - }; - } - }), - }; - } else { - return { - ...mapGroup, - maps: [], - }; - } - }); - // Set to undefined if 'auto' so we can know to set this value // with the layer's histogram response instead const vmin = @@ -97,7 +34,10 @@ export async function fetchInitialState() { vmax, }; - return { defaultLayer, mapGroupSummaries, mapGroups }; + return { + defaultLayer, + defaultMenuState: defaultLayerData.default_layer_menu, + }; } export async function fetchLayer( @@ -129,44 +69,6 @@ export async function fetchLayer( } } -export async function fetchMaps() { - // Get the list of map groups and unpack the response - const mapGroups: MapGroupResponse[] = await ( - await fetch(`${SERVICE_URL}/maps`) - ).json(); - - const internalBaselayers: InternalBaselayer[] = []; - - mapGroups.forEach((mapGroup) => { - mapGroup.maps.forEach((map) => - map.bands.forEach((band) => - band.layers.forEach((layer) => { - // 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; - - const internalBaselayer: InternalBaselayer = { - ...layer, - mapId: map.map_id, - bandId: band.band_id, - isLogScale: false, - isAbsoluteValue: false, - vmin, - vmax, - }; - internalBaselayers.push(internalBaselayer); - }) - ) - ); - }); - - return { - mapGroups, - internalBaselayers, - }; -} - export async function fetchSources() { // Get the list of source groups and unpack the response const sourceGroups: SourceGroupResponse[] = await ( diff --git a/src/utils/filterUtils.ts b/src/utils/filterUtils.ts index cbdf93a..1a52d2a 100644 --- a/src/utils/filterUtils.ts +++ b/src/utils/filterUtils.ts @@ -1,9 +1,9 @@ import { - MapGroupResponse, - MapResponse, - BandResponse, - LayerResponse, -} from '../types/maps'; + MapGroup, + MapSummary, + BandSummary, + LayerSummary, +} from '../types/layers'; import { EXTERNAL_DETAILS_ID } from '../configs/mapSettings'; import { LayerSelectorProps } from '../components/LayerSelector'; @@ -16,7 +16,7 @@ import { LayerSelectorProps } from '../components/LayerSelector'; * @returns A set of IDs that are used to control the open/close state of
elements */ export function getDefaultExpandedState( - mapGroups: MapGroupResponse[], + mapGroups: MapGroup[], activeBaselayerId: LayerSelectorProps['activeBaselayerId'] ) { const expandedState: Set = new Set(); @@ -67,7 +67,7 @@ function match(name: string, query: string) { * filter. The set of matchedIds is used to determine which node should have the * elements applied to the matching search query. */ -export function filterMapGroups(mapGroups: MapGroupResponse[], query: string) { +export function filterMapGroups(mapGroups: MapGroup[], query: string) { const matchedIds = new Set(); const filteredMapGroups = mapGroups @@ -113,21 +113,21 @@ export function filterMapGroups(mapGroups: MapGroupResponse[], query: string) { return { ...band, layers: filteredLayers }; } }) - .filter(Boolean) as BandResponse[]; + .filter(Boolean) as BandSummary[]; // Otherwise include map only if any band matched if (filteredBands.length > 0) { return { ...map, bands: filteredBands }; } }) - .filter(Boolean) as MapResponse[]; + .filter(Boolean) as MapSummary[]; // Otherwise include group only if any map matched if (filteredMaps.length > 0) { return { ...group, maps: filteredMaps }; } }) - .filter(Boolean) as MapGroupResponse[]; + .filter(Boolean) as MapGroup[]; return { filteredMapGroups, matchedIds }; } @@ -139,7 +139,7 @@ export function filterMapGroups(mapGroups: MapGroupResponse[], query: string) { * @returns The ID of a node */ export function getNodeId( - node: MapGroupResponse | MapResponse | BandResponse | LayerResponse + node: MapGroup | MapSummary | BandSummary | LayerSummary ) { let id = node.name; if ('layer_id' in node) { diff --git a/src/utils/layerUtils.ts b/src/utils/layerUtils.ts index 19dbbeb..c0fc812 100644 --- a/src/utils/layerUtils.ts +++ b/src/utils/layerUtils.ts @@ -1,5 +1,6 @@ import { Feature } from 'ol'; -import { BoxExtent, SourceData } from '../types/maps'; +import { SourceData } from '../types/sources'; +import { BoxExtent } from '../types/submaps'; import { CATALOG_COLORWAY, NUMBER_OF_FIXED_COORDINATE_DECIMALS, From 2cf5911aa490c7339acd018a44e78d5caeb5459e Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Fri, 1 May 2026 14:14:59 -0400 Subject: [PATCH 04/10] Refactor layer menu for tilemaker v3 --- eslint.config.js | 8 + src/App.tsx | 26 +- src/components/CollapsibleSection.tsx | 122 ------ ...ions.tsx => ExternalBaselayersSection.tsx} | 50 +-- src/components/InternalBaselayersTree.tsx | 247 ++++++++++++ src/components/LayerSelector.tsx | 133 +++++-- src/components/OpenLayersMap.tsx | 25 +- src/components/styles/layer-selector.css | 8 + src/hooks/useBaselayerChange.ts | 9 +- src/hooks/useLayerMenu.ts | 267 +++++++++++++ src/hooks/useLayerRegistry.ts | 2 +- src/reducers/baselayersReducer.ts | 2 +- src/reducers/layerMenuReducer.ts | 354 ++++++++++++++++++ src/types/layers.ts | 57 ++- src/utils/fetchUtils.ts | 50 ++- src/utils/filterUtils.ts | 153 -------- 16 files changed, 1128 insertions(+), 385 deletions(-) delete mode 100644 src/components/CollapsibleSection.tsx rename src/components/{BaselayerSections.tsx => ExternalBaselayersSection.tsx} (69%) create mode 100644 src/components/InternalBaselayersTree.tsx create mode 100644 src/hooks/useLayerMenu.ts create mode 100644 src/reducers/layerMenuReducer.ts delete mode 100644 src/utils/filterUtils.ts 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 434ee30..664e4ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState, useReducer, ChangeEvent } from 'react'; -import { DefaultLayer, MapGroup } from './types/layers'; +import { DefaultData } from './types/layers'; import { SourceGroup } from './types/sources'; import { Box } from './types/submaps'; import { ColorMapControls } from './components/ColorMapControls'; @@ -37,15 +37,21 @@ function App() { const [flipTiles, setFlipTiles] = useState(true); /** query the map groups to use as the baselayers of the map */ - const { data: defaultLayerData, isLoading: areMapGroupsLoading } = useQuery< - { defaultLayer: DefaultLayer; defaultMenuState: MapGroup[] } | undefined + const { data: defaultData, isLoading: areMapGroupsLoading } = 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 { defaultMenuState, defaultLayer } = await fetchInitialState(); + const { + defaultMenuState, + defaultLayer, + defaultMapGroupId, + defaultBandId, + defaultMapId, + } = await fetchInitialState(); if (!defaultLayer) { // If we end up with no maps, SET_BASELAYERS_STATE will fall back to an external baselayer as its default initial baselayer @@ -79,7 +85,13 @@ function App() { }); } - return { defaultMenuState, defaultLayer }; + return { + defaultMenuState, + defaultLayer, + defaultMapId, + defaultMapGroupId, + defaultBandId, + }; }, }); @@ -246,9 +258,9 @@ function App() { {isAuthenticated !== null && activeBaselayer && internalBaselayers && - defaultLayerData?.defaultMenuState && ( + defaultData && ( ; - 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/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)} />