diff --git a/src/App.tsx b/src/App.tsx index ffbb72b..c426179 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { MapMetadataResponse, Band, SourceList } from './types/maps'; import { ColorMapControls } from './components/ColorMapControls'; import { fetchProducts } from './utils/fetchUtils'; import { + assertBand, baselayerReducer, CHANGE_BASELAYER, CHANGE_CMAP_TYPE, @@ -13,6 +14,7 @@ import { useQuery } from './hooks/useQuery'; import { useHighlightBoxes } from './hooks/useHighlightBoxes'; import { OpenLayersMap } from './components/OpenLayersMap'; import { handleSelectChange } from './utils/layerUtils'; +import { EXTERNAL_BASELAYERS } from './configs/mapSettings'; function App() { /** contains useful state of the baselayer for tile requests and matplotlib color mapping */ @@ -98,10 +100,12 @@ function App() { * TileLayer requests. */ const onBaseLayerChange = useCallback( - (selectedBaselayerId: number) => { - const newActiveBaselayer = bands?.find( - (b) => b.id === selectedBaselayerId - ); + (selectedBaselayerId: string) => { + const isExternalBaselayer = selectedBaselayerId.includes('external'); + + const newActiveBaselayer = isExternalBaselayer + ? EXTERNAL_BASELAYERS.find((b) => b.id === selectedBaselayerId) + : bands?.find((b) => b.id === Number(selectedBaselayerId)); if (!newActiveBaselayer) return; @@ -149,7 +153,7 @@ function App() { /** 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 (baselayerState.activeBaselayer) { + if (assertBand(baselayerState.activeBaselayer)) { const { map_id: mapId, id: bandId } = baselayerState.activeBaselayer; const { cmap, cmapValues } = baselayerState; return { @@ -162,33 +166,27 @@ function App() { } }, [baselayerState]); - if ( - !baselayerState.activeBaselayer || - !baselayerState.cmap || - !baselayerState.cmapValues - ) { - return null; - } else { - const { activeBaselayer, cmap, cmapValues } = baselayerState; - return ( - <> - {baselayerState.activeBaselayer && bands && ( - - )} + const { activeBaselayer, cmap, cmapValues } = baselayerState; + return ( + <> + {baselayerState.activeBaselayer && bands && ( + + )} + {assertBand(activeBaselayer) && cmap && cmapValues && ( - - ); - } + )} + + ); } export default App; diff --git a/src/components/AddBoxDialog.tsx b/src/components/AddBoxDialog.tsx index c8cde3d..eea018e 100644 --- a/src/components/AddBoxDialog.tsx +++ b/src/components/AddBoxDialog.tsx @@ -12,6 +12,7 @@ type AddBoxDialogProps = { setActiveBoxIds: MapProps['setActiveBoxIds']; addOptimisticHighlightBox: MapProps['addOptimisticHighlightBox']; handleAddBoxCleanup: () => void; + flipped: boolean; }; export function AddBoxDialog({ @@ -22,6 +23,7 @@ export function AddBoxDialog({ setActiveBoxIds, addOptimisticHighlightBox, handleAddBoxCleanup, + flipped, }: AddBoxDialogProps) { const [boxName, setBoxName] = useState(''); const [boxDescription, setBoxDescription] = useState(''); @@ -51,6 +53,7 @@ export function AddBoxDialog({ addSubmapAsBox( boxData, + flipped, setBoxes, setActiveBoxIds, addOptimisticHighlightBox @@ -69,6 +72,7 @@ export function AddBoxDialog({ addOptimisticHighlightBox, setActiveBoxIds, setBoxes, + flipped, ] ); diff --git a/src/components/BoxMenu.tsx b/src/components/BoxMenu.tsx index 2d26190..73a8494 100644 --- a/src/components/BoxMenu.tsx +++ b/src/components/BoxMenu.tsx @@ -3,6 +3,7 @@ import { BoxWithPositionalData, NewBoxData, SubmapData } from '../types/maps'; import { MenuIcon } from './icons/MenuIcon'; import { SUBMAP_DOWNLOAD_OPTIONS } from '../configs/submapConfigs'; import { downloadSubmap } from '../utils/fetchUtils'; +import { transformBoxes } from '../utils/layerUtils'; type BoxMenuProps = { isNewBox: boolean; @@ -12,6 +13,7 @@ type BoxMenuProps = { additionalButtons?: ReactNode[]; submapData?: SubmapData; showMenuOverlay?: boolean; + flipped: boolean; }; export function BoxMenu({ @@ -22,6 +24,7 @@ export function BoxMenu({ additionalButtons = [], submapData, showMenuOverlay, + flipped, }: BoxMenuProps) { return (
{ if (submapData) { + const boxPosition = transformBoxes( + { + top_left_ra: boxData.top_left_ra, + top_left_dec: boxData.top_left_dec, + bottom_right_ra: boxData.bottom_right_ra, + bottom_right_dec: boxData.bottom_right_dec, + }, + flipped + ); downloadSubmap( { ...submapData, - top: boxData.top_left_dec, - left: boxData.top_left_ra, - bottom: boxData.bottom_right_dec, - right: boxData.bottom_right_ra, + top: boxPosition.top_left_dec, + left: boxPosition.top_left_ra, + bottom: boxPosition.bottom_right_dec, + right: boxPosition.bottom_right_ra, }, option.ext ); diff --git a/src/components/CoordinatesDisplay.tsx b/src/components/CoordinatesDisplay.tsx index 361c025..e928874 100644 --- a/src/components/CoordinatesDisplay.tsx +++ b/src/components/CoordinatesDisplay.tsx @@ -1,15 +1,23 @@ import { NUMBER_OF_FIXED_COORDINATE_DECIMALS } from '../configs/mapSettings'; +import { transformGraticuleCoords } from '../utils/layerUtils'; import './styles/coordinates-display.css'; -export function CoordinatesDisplay({ coordinates }: { coordinates: number[] }) { +export function CoordinatesDisplay({ + coordinates, + flipped, +}: { + coordinates: number[]; + flipped: boolean; +}) { + const transformedCoords = transformGraticuleCoords(coordinates, flipped); return (
( - {coordinates[0].toFixed(NUMBER_OF_FIXED_COORDINATE_DECIMALS)} , + {transformedCoords[0].toFixed(NUMBER_OF_FIXED_COORDINATE_DECIMALS)} , - {coordinates[1].toFixed(NUMBER_OF_FIXED_COORDINATE_DECIMALS)} + {transformedCoords[1].toFixed(NUMBER_OF_FIXED_COORDINATE_DECIMALS)} )
diff --git a/src/components/LayerSelector.tsx b/src/components/LayerSelector.tsx index 44a4877..863f51f 100644 --- a/src/components/LayerSelector.tsx +++ b/src/components/LayerSelector.tsx @@ -4,6 +4,7 @@ import { makeLayerName } from '../utils/layerUtils'; import { LayersIcon } from './icons/LayersIcon'; import './styles/layer-selector.css'; import { MapProps } from './OpenLayersMap'; +import { EXTERNAL_BASELAYERS } from '../configs/mapSettings'; interface Props extends Omit< @@ -14,8 +15,9 @@ interface Props | 'setBoxes' | 'addOptimisticHighlightBox' > { - activeBaselayerId?: number; + activeBaselayerId?: number | string; sourceLists: SourceList[]; + isFlipped: boolean; } export function LayerSelector({ @@ -28,6 +30,7 @@ export function LayerSelector({ highlightBoxes, activeBoxIds, onSelectedHighlightBoxChange, + isFlipped, }: Props) { const menuRef = useRef(null); @@ -65,13 +68,30 @@ export function LayerSelector({ value={band.id} name="baselayer" checked={band.id === activeBaselayerId} - onChange={(e) => onBaseLayerChange(Number(e.target.value))} + onChange={(e) => onBaseLayerChange(e.target.value)} />
))} + {EXTERNAL_BASELAYERS.map((bl) => ( +
+ onBaseLayerChange(e.target.value)} + disabled={bl.disabledState(isFlipped)} + /> + +
+ ))} - {sourceLists.length && ( + {sourceLists.length ? (
Source catalogs {sourceLists.map((sl) => ( @@ -87,8 +107,8 @@ export function LayerSelector({ ))}
- )} - {highlightBoxes && ( + ) : null} + {highlightBoxes && highlightBoxes.length ? (
Highlight regions {highlightBoxes.map((box) => ( @@ -104,7 +124,7 @@ export function LayerSelector({ ))}
- )} + ) : null} ); diff --git a/src/components/OpenLayersMap.tsx b/src/components/OpenLayersMap.tsx index d8535d1..09cac1f 100644 --- a/src/components/OpenLayersMap.tsx +++ b/src/components/OpenLayersMap.tsx @@ -1,5 +1,12 @@ -import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; -import { Map, View, Feature } from 'ol'; +import { + ChangeEvent, + useCallback, + useEffect, + useMemo, + 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'; @@ -17,7 +24,11 @@ import { SourceList, SubmapData, } from '../types/maps'; -import { DEFAULT_MAP_SETTINGS, SERVICE_URL } from '../configs/mapSettings'; +import { + DEFAULT_INTERNAL_MAP_SETTINGS, + EXTERNAL_BASELAYERS, + SERVICE_URL, +} from '../configs/mapSettings'; import { CoordinatesDisplay } from './CoordinatesDisplay'; import { LayerSelector } from './LayerSelector'; import { CropIcon } from './icons/CropIcon'; @@ -28,11 +39,18 @@ import { AddHighlightBoxLayer } from './layers/AddHighlightBoxLayer'; import { generateSearchContent } from '../utils/externalSearchUtils'; import './styles/highlight-controls.css'; import './styles/area-selection.css'; +import { assertBand } from '../reducers/baselayerReducer'; +import { + getBaselayerResolutions, + transformCoords, + transformGraticuleCoords, +} from '../utils/layerUtils'; +import { ToggleSwitch } from './ToggleSwitch'; export type MapProps = { bands: Band[]; baselayerState: BaselayerState; - onBaseLayerChange: (baselayerId: number) => void; + onBaseLayerChange: (baselayerId: string) => void; sourceLists?: SourceList[]; activeSourceListIds: number[]; onSelectedSourceListsChange: (e: ChangeEvent) => void; @@ -63,30 +81,33 @@ export function OpenLayersMap({ const mapRef = useRef(null); const drawBoxRef = useRef(null); const externalSearchRef = useRef(null); + const externalSearchMarkerRef = useRef(null); + const previousSearchOverlayHandlerRef = + useRef<(e: MapBrowserEvent) => void | null>(null); const [coordinates, setCoordinates] = useState( undefined ); const [isDrawing, setIsDrawing] = useState(false); + const [isNewBoxDrawn, setIsNewBoxDrawn] = useState(false); + const [flipTiles, setFlipTiles] = useState(false); const { activeBaselayer, cmap, cmapValues } = baselayerState; const tileLayers = useMemo(() => { return bands.map((band) => { - const resolutionZ0 = 180 / band.tile_size; - const levels = band.levels; - const resolutions = []; - for (let i = 0; i < levels; i++) { - resolutions.push(resolutionZ0 / 2 ** i); - } return new TileLayer({ properties: { id: 'baselayer-' + band.id }, source: new XYZ({ - url: `${SERVICE_URL}/maps/${band.map_id}/${band.id}/{z}/{-y}/{x}/tile.png?cmap=${cmap}&vmin=${cmapValues?.min}&vmax=${cmapValues?.max}`, + url: `${SERVICE_URL}/maps/${band.map_id}/${band.id}/{z}/{-y}/{x}/tile.png?cmap=${cmap}&vmin=${cmapValues?.min}&vmax=${cmapValues?.max}&flip=${flipTiles}`, tileGrid: new TileGrid({ extent: [-180, -90, 180, 90], origin: [-180, 90], tileSize: band.tile_size, - resolutions, + resolutions: getBaselayerResolutions( + 180, + band.tile_size, + band.levels + ), }), interpolate: false, projection: 'EPSG:4326', @@ -96,6 +117,29 @@ export function OpenLayersMap({ }); }, [bands]); + const externalTileLayers = useMemo(() => { + return EXTERNAL_BASELAYERS.map((b) => { + return new TileLayer({ + properties: { id: b.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, + 9 + ), + origin: [b.extent[0], b.extent[3]], + }), + wrapX: true, + }), + }); + }); + }, []); + /** * Create the map with a scale control, a layer for the "add box" functionality * and a 'pointermove' interaction for the coordinate display @@ -105,7 +149,7 @@ export function OpenLayersMap({ if (!stableMapRef) { mapRef.current = new Map({ target: 'map', - view: new View(DEFAULT_MAP_SETTINGS), + view: new View(DEFAULT_INTERNAL_MAP_SETTINGS), }); mapRef.current.on('pointermove', (e) => { @@ -141,8 +185,11 @@ export function OpenLayersMap({ }) ); + externalSearchMarkerRef.current = externalSearchMarker; + const externalSearchMarkerSource = new VectorSource({ features: [externalSearchMarker], + wrapX: false, }); const externalSearchMarkerLayer = new VectorLayer({ @@ -152,37 +199,6 @@ export function OpenLayersMap({ mapRef.current.addLayer(externalSearchMarkerLayer); - // Create the click handler that displays/hides the marker and the popup - mapRef.current.on('click', (e) => { - if (e.originalEvent.metaKey) { - const simbadOverlay = e.map.getOverlayById('simbad-search-overlay'); - if (simbadOverlay) { - if (externalSearchRef.current) { - while (externalSearchRef.current.firstChild) { - externalSearchRef.current.removeChild( - externalSearchRef.current.firstChild - ); - } - } - externalSearchRef.current?.append( - generateSearchContent(e.coordinate) - ); - simbadOverlay.setPosition(e.coordinate); - externalSearchMarker.setGeometry(new Point(e.coordinate)); - } - } else { - const simbadOverlay = e.map.getOverlayById('simbad-search-overlay'); - if (simbadOverlay) { - externalSearchRef.current!.innerHTML = ''; - simbadOverlay.setPosition(undefined); - externalSearchMarker.setGeometry(undefined); - } - } - }); - /** - * END - */ - mapRef.current.addControl( new ScaleLine({ className: 'scale-control', @@ -191,7 +207,9 @@ export function OpenLayersMap({ ); // create a source and layer for the "add box" functionality - const boxSource = new VectorSource(); + const boxSource = new VectorSource({ + wrapX: false, + }); const boxVector = new VectorLayer({ source: boxSource, properties: { @@ -208,6 +226,82 @@ export function OpenLayersMap({ }; }, []); + const handleSearchOverlay = useCallback( + (e: MapBrowserEvent) => { + if (e.originalEvent.metaKey) { + const simbadOverlay = e.map.getOverlayById('simbad-search-overlay'); + if (simbadOverlay) { + if (externalSearchRef.current) { + while (externalSearchRef.current.firstChild) { + externalSearchRef.current.removeChild( + externalSearchRef.current.firstChild + ); + } + } + const overlayCoords = e.coordinate; + const searchCoords = transformGraticuleCoords( + overlayCoords, + flipTiles + ); + externalSearchRef.current?.append( + generateSearchContent(searchCoords) + ); + simbadOverlay.setPosition(overlayCoords); + externalSearchMarkerRef.current?.setGeometry( + new Point(overlayCoords) + ); + } + } else { + const simbadOverlay = e.map.getOverlayById('simbad-search-overlay'); + if (simbadOverlay) { + externalSearchRef.current!.innerHTML = ''; + simbadOverlay.setPosition(undefined); + externalSearchMarkerRef.current?.setGeometry(undefined); + } + } + }, + [externalSearchMarkerRef.current, flipTiles] + ); + + useEffect(() => { + if (mapRef.current) { + if (previousSearchOverlayHandlerRef.current) { + mapRef.current.un('click', previousSearchOverlayHandlerRef.current); + } + previousSearchOverlayHandlerRef.current = handleSearchOverlay; + mapRef.current.on('click', handleSearchOverlay); + } + }, [handleSearchOverlay]); + + useEffect(() => { + if (mapRef.current) { + const simbadOverlay = mapRef.current.getOverlayById( + 'simbad-search-overlay' + ); + if (simbadOverlay) { + const coords = simbadOverlay.getPosition(); + if (coords) { + if (externalSearchRef.current) { + while (externalSearchRef.current.firstChild) { + externalSearchRef.current.removeChild( + externalSearchRef.current.firstChild + ); + } + } + const searchCoords = transformCoords(coords, flipTiles, 'search'); + const overlayCoords = transformCoords(coords, flipTiles, 'layer'); + externalSearchRef.current?.append( + generateSearchContent(searchCoords) + ); + simbadOverlay.setPosition(overlayCoords); + externalSearchMarkerRef.current?.setGeometry( + new Point(overlayCoords) + ); + } + } + } + }, [flipTiles]); + /** * Updates tilelayers when new baselayer is selected and/or color map settings change */ @@ -216,38 +310,48 @@ export function OpenLayersMap({ mapRef.current.getAllLayers().forEach((layer) => { const layerId = layer.get('id'); if (!layerId) return; - if (layerId.includes('baselayer')) { + if (layerId.includes('baselayer') || layerId.includes('external')) { mapRef.current?.removeLayer(layer); } }); - const resolutionZ0 = 180 / activeBaselayer.tile_size; - const levels = activeBaselayer.levels; - const resolutions = []; - for (let i = 0; i < levels; i++) { - resolutions.push(resolutionZ0 / 2 ** i); + if (assertBand(activeBaselayer)) { + const url = `${SERVICE_URL}/maps/${activeBaselayer.map_id}/${activeBaselayer.id}/{z}/{-y}/{x}/tile.png?cmap=${cmap}&vmin=${cmapValues?.min}&vmax=${cmapValues?.max}&flip=${flipTiles}`; + const activeLayer = tileLayers.find( + (t) => t.get('id') === 'baselayer-' + activeBaselayer!.id + )!; + activeLayer.getSource()?.setUrl(url); + mapRef.current.addLayer(activeLayer); + } else { + const externalBaselayer = EXTERNAL_BASELAYERS.find( + (b) => b.id === activeBaselayer.id + ); + const activeLayer = externalTileLayers.find( + (t) => t.get('id') === activeBaselayer.id + )!; + + if (!externalBaselayer || !activeLayer) return; + + mapRef.current.addLayer(activeLayer); } - const source = new XYZ({ - url: `${SERVICE_URL}/maps/${activeBaselayer.map_id}/${activeBaselayer.id}/{z}/{-y}/{x}/tile.png?cmap=${cmap}&vmin=${cmapValues?.min}&vmax=${cmapValues?.max}`, - tileGrid: new TileGrid({ - extent: [-180, -90, 180, 90], - origin: [-180, 90], - tileSize: activeBaselayer.tile_size, - resolutions, - }), - interpolate: false, - projection: 'EPSG:4326', - tilePixelRatio: activeBaselayer.tile_size / 256, - }); - const activeLayer = tileLayers.find( - (t) => t.get('id') === 'baselayer-' + activeBaselayer!.id - )!; - activeLayer.setSource(source); - mapRef.current.addLayer(activeLayer); } - }, [activeBaselayer, cmap, cmapValues, tileLayers]); + }, [activeBaselayer, cmap, cmapValues, tileLayers, flipTiles]); + + const disableToggleForNewBox = isDrawing || isNewBoxDrawn; return (
+ { + setFlipTiles(!flipTiles); + }} + disabled={!assertBand(activeBaselayer) || disableToggleForNewBox} + disabledMessage={ + disableToggleForNewBox + ? 'You cannot switch when drawing a new highlight region.' + : 'You cannot switch to an incompatible RA range.' + } + />
); } diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..f4f2ce8 --- /dev/null +++ b/src/components/ToggleSwitch.tsx @@ -0,0 +1,41 @@ +import './styles/toggle-switch.css'; + +type ToggleSwitchProps = { + checked: boolean; + onChange: () => void; + disabled: boolean; + disabledMessage: string; +}; + +export function ToggleSwitch({ + checked, + onChange, + disabled, + disabledMessage, +}: ToggleSwitchProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/layers/AddHighlightBoxLayer.tsx b/src/components/layers/AddHighlightBoxLayer.tsx index 567d8d9..29e59d2 100644 --- a/src/components/layers/AddHighlightBoxLayer.tsx +++ b/src/components/layers/AddHighlightBoxLayer.tsx @@ -12,10 +12,12 @@ type AddHightlightBoxLayerProps = { drawBoxRef: React.RefObject; isDrawing: boolean; setIsDrawing: (drawing: boolean) => void; + setIsNewBoxDrawn: (drawn: boolean) => void; submapData: MapProps['submapData']; setBoxes: MapProps['setBoxes']; setActiveBoxIds: MapProps['setActiveBoxIds']; addOptimisticHighlightBox: MapProps['addOptimisticHighlightBox']; + flipped: boolean; }; export function AddHighlightBoxLayer({ @@ -23,12 +25,13 @@ export function AddHighlightBoxLayer({ drawBoxRef, isDrawing, setIsDrawing, + setIsNewBoxDrawn, submapData, setBoxes, setActiveBoxIds, addOptimisticHighlightBox, + flipped, }: AddHightlightBoxLayerProps) { - // const drawBoxRef = useRef(null); const drawRef = useRef(null); const [newBoxData, setNewBoxData] = useState( undefined @@ -92,6 +95,7 @@ export function AddHighlightBoxLayer({ } setIsDrawing(false); + setIsNewBoxDrawn(true); }); map.addInteraction(draw); drawRef.current = draw; @@ -127,6 +131,7 @@ export function AddHighlightBoxLayer({ const handleAddBoxCleanup = useCallback(() => { setNewBoxData(undefined); + setIsNewBoxDrawn(false); const source = drawBoxRef.current?.getSource(); if (source) { source.clear(); @@ -138,6 +143,7 @@ export function AddHighlightBoxLayer({ {newBoxData && ( ); diff --git a/src/components/layers/GraticuleLayer.tsx b/src/components/layers/GraticuleLayer.tsx index 333e542..2981c3d 100644 --- a/src/components/layers/GraticuleLayer.tsx +++ b/src/components/layers/GraticuleLayer.tsx @@ -4,11 +4,20 @@ import Stroke from 'ol/style/Stroke'; export function GraticuleLayer({ mapRef, + flipped, }: { mapRef: React.RefObject; + flipped: boolean; }) { useEffect(() => { if (mapRef.current) { + mapRef.current.getAllLayers().forEach((l) => { + const id = l.getProperties().id; + if (id && id.includes('graticule-')) { + mapRef.current?.removeLayer(l); + } + }); + const graticule1 = new Graticule({ strokeStyle: new Stroke({ color: 'rgba(198,198,198,0.5)', @@ -19,7 +28,7 @@ export function GraticuleLayer({ lonLabelPosition: 0, latLabelPosition: 0.999, latLabelFormatter: (lat) => String(lat), - lonLabelFormatter: (lon) => String(lon), + lonLabelFormatter: (lon) => String(flipped ? lon * -1 + 180 : lon), wrapX: false, properties: { id: 'graticule-1', @@ -36,7 +45,7 @@ export function GraticuleLayer({ lonLabelPosition: 1, latLabelPosition: 0.012, latLabelFormatter: (lat) => String(lat), - lonLabelFormatter: (lon) => String(lon), + lonLabelFormatter: (lon) => String(flipped ? lon * -1 + 180 : lon), wrapX: false, properties: { id: 'graticule-2', @@ -46,7 +55,7 @@ export function GraticuleLayer({ mapRef.current.addLayer(graticule1); mapRef.current.addLayer(graticule2); } - }, [mapRef.current]); + }, [mapRef.current, flipped]); return null; } diff --git a/src/components/layers/HighlightBoxLayer.tsx b/src/components/layers/HighlightBoxLayer.tsx index b3de839..13e810a 100644 --- a/src/components/layers/HighlightBoxLayer.tsx +++ b/src/components/layers/HighlightBoxLayer.tsx @@ -1,13 +1,14 @@ -import { useRef, useState, useMemo, useEffect } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import { Box, BoxWithPositionalData } from '../../types/maps'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; -import { Feature, Map, Overlay } from 'ol'; -import { fromExtent } from 'ol/geom/Polygon'; +import { Feature, Map, MapBrowserEvent, Overlay } from 'ol'; +import Polygon, { fromExtent } from 'ol/geom/Polygon'; import { MenuIcon } from '../icons/MenuIcon'; import { MapProps } from '../OpenLayersMap'; import { deleteSubmapBox } from '../../utils/fetchUtils'; import { BoxMenu } from '../BoxMenu'; +import { transformBoxes, isBoxSynced } from '../../utils/layerUtils'; type HightlightBoxLayerProps = { highlightBoxes: MapProps['highlightBoxes']; @@ -16,6 +17,7 @@ type HightlightBoxLayerProps = { setActiveBoxIds: MapProps['setActiveBoxIds']; setBoxes: MapProps['setBoxes']; submapData: MapProps['submapData']; + flipped: boolean; }; export function HighlightBoxLayer({ @@ -25,100 +27,224 @@ export function HighlightBoxLayer({ setActiveBoxIds, setBoxes, submapData, + flipped, }: HightlightBoxLayerProps) { const boxOverlayRef = useRef(null); const [selectedBoxData, setSelectedBoxData] = useState< BoxWithPositionalData | undefined >(undefined); const [showMenu, setShowMenu] = useState(false); + const addedLayerIdsRef = useRef>(new Set()); + const overlayRef = useRef(null); + const handleBoxHoverRef = useRef<(e: MapBrowserEvent) => void>(null); - const highlightBoxOverlays = useMemo(() => { - if (!highlightBoxes) return []; - return highlightBoxes - .filter((box) => activeBoxIds.includes(box.id)) - .map( - (box) => - new VectorLayer({ - properties: { - id: 'highlight-box-' + box.id, - }, - source: new VectorSource({ - features: [ - new Feature({ - geometry: fromExtent([ - box.top_left_ra, - box.bottom_right_dec, - box.bottom_right_ra, - box.top_left_dec, - ]), // minX, minY, maxX, maxY - boxData: box, - }), - ], - }), - zIndex: 500, - }) - ); - }, [highlightBoxes, activeBoxIds]); + const handleBoxHover = useCallback( + (e: MapBrowserEvent) => { + if (!e.map.hasFeatureAtPixel(e.pixel)) { + overlayRef.current?.setPosition(undefined); + setSelectedBoxData(undefined); + setShowMenu(false); + return; + } + + e.map.forEachFeatureAtPixel(e.pixel, (f) => { + const boxData = f.get('boxData') as Box; + if (!boxData) return; + + const topLeft = e.map.getPixelFromCoordinate([ + boxData.top_left_ra, + boxData.top_left_dec, + ]); + const bottomRight = e.map.getPixelFromCoordinate([ + boxData.bottom_right_ra, + boxData.bottom_right_dec, + ]); + + const width = Math.abs(topLeft[0] - bottomRight[0]); + const height = Math.abs(topLeft[1] - bottomRight[1]); + + overlayRef.current!.setPosition([ + boxData.top_left_ra, + boxData.top_left_dec, + ]); + + setSelectedBoxData({ + ...boxData, + top: topLeft[1], + left: topLeft[0], + width, + height, + }); + }); + }, + [flipped] + ); useEffect(() => { - if (!mapRef.current) return; - mapRef.current.getLayers().forEach((l) => { - const id = l.get('id') as string; - if (typeof id === 'string' && id.includes('highlight-box')) { - l.setVisible(false); + if (!mapRef.current || !highlightBoxes?.length) return; + + const map = mapRef.current; + const existingLayers = map.getLayers().getArray(); + + // Build a Set of current valid highlight box IDs + const validBoxIds = new Set( + highlightBoxes.filter((b) => activeBoxIds.includes(b.id)).map((b) => b.id) + ); + + // Remove old layers not in current valid list + existingLayers.forEach((layer) => { + const id = layer.get('id'); + if (typeof id === 'string' && id.startsWith('highlight-box-')) { + const boxId = id.replace('highlight-box-', ''); + if (!validBoxIds.has(Number(boxId))) { + map.removeLayer(layer); + addedLayerIdsRef.current.delete(id); + } } }); - highlightBoxOverlays.forEach((box) => { - mapRef.current?.addLayer(box); + + // Add new layers for valid boxes not yet added + highlightBoxes.forEach((box) => { + const layerId = `highlight-box-${box.id}`; + if (!activeBoxIds.includes(box.id)) return; + if (addedLayerIdsRef.current.has(layerId)) return; + + const boxPosition = transformBoxes( + { + top_left_ra: box.top_left_ra, + top_left_dec: box.top_left_dec, + bottom_right_ra: box.bottom_right_ra, + bottom_right_dec: box.bottom_right_dec, + }, + flipped + ); + + const layer = new VectorLayer({ + properties: { id: layerId }, + source: new VectorSource({ + features: [ + new Feature({ + geometry: fromExtent([ + boxPosition.top_left_ra, + boxPosition.bottom_right_dec, + boxPosition.bottom_right_ra, + boxPosition.top_left_dec, + ]), + boxData: { + ...box, + ...boxPosition, + }, + }), + ], + wrapX: false, + }), + zIndex: 500, + }); + + map?.addLayer(layer); + addedLayerIdsRef.current.add(layerId); }); - }, [highlightBoxOverlays]); + }, [mapRef.current, highlightBoxes, activeBoxIds, flipped]); useEffect(() => { - if (!mapRef.current) return; - const doesOverlayExist = mapRef.current.getOverlayById('box-overlay'); - if (!doesOverlayExist && boxOverlayRef.current) { - const boxOverlay = new Overlay({ + if (!mapRef.current || !boxOverlayRef.current) return; + if (!overlayRef.current) { + overlayRef.current = new Overlay({ element: boxOverlayRef.current, id: 'box-overlay', }); - mapRef.current.addOverlay(boxOverlay); - mapRef.current.on('pointermove', (e) => { - if (!e.map.hasFeatureAtPixel(e.pixel)) { - boxOverlay.setPosition(undefined); - setSelectedBoxData(undefined); - setShowMenu(false); - return; - } - e.map.forEachFeatureAtPixel(e.pixel, function (f) { - const boxData = f.get('boxData') as Box; - if (!boxData) return; - const topLeftBoxPosition = e.map.getPixelFromCoordinate([ - boxData.top_left_ra, - boxData.top_left_dec, - ]); - const bottomRightBoxPosition = e.map.getPixelFromCoordinate([ - boxData.bottom_right_ra, - boxData.bottom_right_dec, - ]); - const boxWidth = Math.abs( - topLeftBoxPosition[0] - bottomRightBoxPosition[0] - ); - const boxHeight = Math.abs( - topLeftBoxPosition[1] - bottomRightBoxPosition[1] - ); - boxOverlay.setPosition([boxData.top_left_ra, boxData.top_left_dec]); - setSelectedBoxData({ - ...boxData, - top: topLeftBoxPosition[1], - left: topLeftBoxPosition[0], - width: boxWidth, - height: boxHeight, - }); - }); - }); + mapRef.current.addOverlay(overlayRef.current); } }, [mapRef.current, boxOverlayRef.current]); + useEffect(() => { + if (mapRef.current) { + if (handleBoxHoverRef.current) { + mapRef.current.un('pointermove', handleBoxHoverRef.current); + } + handleBoxHoverRef.current = handleBoxHover; + mapRef.current.on('pointermove', handleBoxHover); + } + }, [mapRef.current, handleBoxHover]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.getAllLayers().forEach((l) => { + const layerId = l.getProperties()['id'] as string; + if (addedLayerIdsRef.current.has(layerId)) { + const vectorSource = l.getSource(); + if (vectorSource instanceof VectorSource) { + const feature = vectorSource.getFeatures()[0]; + const box = feature.getGeometry() as Polygon; + const currentData = feature.get('boxData'); + const id = Number(layerId.replace('highlight-box-', '')); + const originalBox = highlightBoxes?.find((b) => b.id === id); + + if (!originalBox) return; + + const originalExtent = { + top_left_ra: originalBox.top_left_ra, + bottom_right_dec: originalBox.bottom_right_dec, + bottom_right_ra: originalBox.bottom_right_ra, + top_left_dec: originalBox.top_left_dec, + }; + + if (!flipped) { + box.setCoordinates([ + [ + [originalBox.top_left_ra, originalBox.top_left_dec], + [originalBox.bottom_right_ra, originalBox.top_left_dec], + [originalBox.bottom_right_ra, originalBox.bottom_right_dec], + [originalBox.top_left_ra, originalBox.bottom_right_dec], + [originalBox.top_left_ra, originalBox.top_left_dec], + ], + ]); + const newData = { + ...currentData, + top_left_ra: originalBox.top_left_ra, + top_left_dec: originalBox.top_left_dec, + bottom_right_ra: originalBox.bottom_right_ra, + bottom_right_dec: originalBox.bottom_right_dec, + }; + feature.set('boxData', newData); + } else { + const currentExtentArray = box.getExtent(); + const currentExtent = { + top_left_ra: currentExtentArray[0], + bottom_right_dec: currentExtentArray[1], + bottom_right_ra: currentExtentArray[2], + top_left_dec: currentExtentArray[3], + }; + + if (!isBoxSynced(currentExtent, originalExtent)) { + return; + } + + const newExtent = transformBoxes(currentExtent, flipped); + box.setCoordinates([ + [ + [newExtent.top_left_ra, newExtent.top_left_dec], + [newExtent.bottom_right_ra, newExtent.top_left_dec], + [newExtent.bottom_right_ra, newExtent.bottom_right_dec], + [newExtent.top_left_ra, newExtent.bottom_right_dec], + [newExtent.top_left_ra, newExtent.top_left_dec], + ], + ]); + const newData = { + ...currentData, + top_left_ra: newExtent.top_left_ra, + top_left_dec: newExtent.top_left_dec, + bottom_right_ra: newExtent.bottom_right_ra, + bottom_right_dec: newExtent.bottom_right_dec, + }; + feature.set('boxData', newData); + } + } + } + }); + }, [flipped, highlightBoxes]); + return ( <>
; + flipped: boolean; }; export function SourcesLayer({ sourceLists = [], activeSourceListIds, mapRef, + flipped, }: SourcesLayerProps) { const popupRef = useRef(null); const [selectedSourceData, setSelectedSourceData] = useState< Source | undefined >(undefined); - const sourceOverlays = useMemo(() => { - if (!sourceLists.length) return []; - return sourceLists + const sourceGroupRef = useRef(null); + const selectInteractionRef = useRef