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/BoxMenu.tsx b/src/components/BoxMenu.tsx index 2d26190..e987ad1 100644 --- a/src/components/BoxMenu.tsx +++ b/src/components/BoxMenu.tsx @@ -48,6 +48,7 @@ export function BoxMenu({ { if (submapData) { downloadSubmap( diff --git a/src/components/LayerSelector.tsx b/src/components/LayerSelector.tsx index 44a4877..00e84f4 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,7 +15,7 @@ interface Props | 'setBoxes' | 'addOptimisticHighlightBox' > { - activeBaselayerId?: number; + activeBaselayerId?: number | string; sourceLists: SourceList[]; } @@ -65,13 +66,26 @@ 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)} /> {makeLayerName(band)} ))} + {EXTERNAL_BASELAYERS.map((bl) => ( + + onBaseLayerChange(e.target.value)} + /> + {bl.name} + + ))} - {sourceLists.length && ( + {sourceLists.length ? ( Source catalogs {sourceLists.map((sl) => ( @@ -87,8 +101,8 @@ export function LayerSelector({ ))} - )} - {highlightBoxes && ( + ) : null} + {highlightBoxes && highlightBoxes.length ? ( Highlight regions {highlightBoxes.map((box) => ( @@ -104,7 +118,7 @@ export function LayerSelector({ ))} - )} + ) : null} > ); diff --git a/src/components/OpenLayersMap.tsx b/src/components/OpenLayersMap.tsx index d8535d1..0040af2 100644 --- a/src/components/OpenLayersMap.tsx +++ b/src/components/OpenLayersMap.tsx @@ -1,4 +1,11 @@ -import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { + ChangeEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Map, View, Feature } from 'ol'; import { TileGrid } from 'ol/tilegrid'; import { Tile as TileLayer } from 'ol/layer'; @@ -9,6 +16,12 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { Point } from 'ol/geom'; import { Circle as CircleStyle, Style, Fill, Stroke } from 'ol/style'; +import { + get as getProjection, + getPointResolution, + transform, + toLonLat, +} from 'ol/proj.js'; import 'ol/ol.css'; import { Band, @@ -17,7 +30,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 +45,13 @@ 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 } from '../utils/layerUtils'; export type MapProps = { bands: Band[]; baselayerState: BaselayerState; - onBaseLayerChange: (baselayerId: number) => void; + onBaseLayerChange: (baselayerId: string) => void; sourceLists?: SourceList[]; activeSourceListIds: number[]; onSelectedSourceListsChange: (e: ChangeEvent) => void; @@ -67,26 +86,25 @@ export function OpenLayersMap({ undefined ); const [isDrawing, setIsDrawing] = 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 +114,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,11 +146,17 @@ 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) => { - setCoordinates(e.coordinate); + const units = mapRef.current?.getView().getProjection().getUnits(); + if (units === 'm') { + const lonLat = toLonLat(e.coordinate); + setCoordinates(lonLat); + } else { + setCoordinates(e.coordinate); + } }); /** @@ -164,11 +211,17 @@ export function OpenLayersMap({ ); } } + const mapProj = e.map.getView().getProjection(); + let searchCoords = e.coordinate; + let mapPosition = e.coordinate; + if (mapProj.getCode() === 'EPSG:3857') { + searchCoords = toLonLat(e.coordinate); + } externalSearchRef.current?.append( - generateSearchContent(e.coordinate) + generateSearchContent(searchCoords) ); - simbadOverlay.setPosition(e.coordinate); - externalSearchMarker.setGeometry(new Point(e.coordinate)); + simbadOverlay.setPosition(mapPosition); + externalSearchMarker.setGeometry(new Point(mapPosition)); } } else { const simbadOverlay = e.map.getOverlayById('simbad-search-overlay'); @@ -208,6 +261,50 @@ export function OpenLayersMap({ }; }, []); + const onChangeProjection = useCallback( + (projection: string) => { + // (projection: string, extent: number[]) => { + if (mapRef.current) { + const currentView = mapRef.current.getView(); + const currentProjection = currentView.getProjection(); + const newProjection = getProjection(projection)!; + const currentResolution = currentView.getResolution(); + const currentCenter = currentView.getCenter() ?? [0, 0]; + const currentRotation = currentView.getRotation(); + const newCenter = transform( + currentCenter, + currentProjection, + newProjection + ); + const currentMPU = currentProjection.getMetersPerUnit(); + const newMPU = newProjection!.getMetersPerUnit(); + const currentPointResolution = + getPointResolution( + currentProjection, + 1 / currentMPU!, + currentCenter, + 'm' + ) * currentMPU!; + const newPointResolution = + getPointResolution(newProjection!, 1 / newMPU!, newCenter, 'm') * + newMPU!; + const newResolution = + (currentResolution! * currentPointResolution) / newPointResolution; + const newView = new View({ + center: newCenter, + resolution: newResolution, + rotation: currentRotation, + projection: newProjection, + // extent, + showFullExtent: true, + multiWorld: true, + }); + mapRef.current.setView(newView); + } + }, + [mapRef.current] + ); + /** * Updates tilelayers when new baselayer is selected and/or color map settings change */ @@ -216,38 +313,72 @@ 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 resolutionZ0 = 180 / activeBaselayer.tile_size; + const levels = activeBaselayer.levels; + const resolutions = []; + for (let i = 0; i < levels; i++) { + resolutions.push(resolutionZ0 / 2 ** i); + } + 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}&flip=${flipTiles}`, + 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); + onChangeProjection( + DEFAULT_INTERNAL_MAP_SETTINGS.projection + // DEFAULT_INTERNAL_MAP_SETTINGS.extent, + ); + } 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; + + onChangeProjection( + externalBaselayer.projection + // externalBaselayer.extent, + ); + 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]); return ( + + + { + console.log(e.target.checked); + setFlipTiles(!flipTiles); + }} + /> + {flipTiles ? '360 < ra < 0' : '-180 < ra < 180'} + + { if (!sourceLists.length) return []; + const projection = mapRef.current?.getView().getProjection(); return sourceLists .filter((sl) => activeSourceListIds.includes(sl.id)) .map( @@ -38,13 +40,18 @@ export function SourcesLayer({ layers: [ new VectorLayer({ source: new VectorSource({ - features: sl.sources.map( - (source) => - new Feature({ - geometry: new Circle([source.ra, source.dec], 1), - sourceData: source, - }) - ), + features: sl.sources.map((source) => { + let coords = [source.ra, source.dec]; + let rad = 1; + if (projection && projection.getCode() === 'EPSG:3857') { + coords = transform(coords, 'EPSG:4326', 'EPSG:3857'); + rad = 100000; + } + return new Feature({ + geometry: new Circle(coords, rad), + sourceData: source, + }); + }), }), style: { 'stroke-width': 2, @@ -97,9 +104,15 @@ export function SourcesLayer({ return; } + const projection = mapRef.current?.getView().getProjection(); + selectedFeatures.forEach((feature) => { const sourceData = feature.get('sourceData') as Source; - popupOverlay.setPosition([sourceData.ra, sourceData.dec]); + let coords = [sourceData.ra, sourceData.dec]; + if (projection && projection.getCode() === 'EPSG:3857') { + coords = transform(coords, 'EPSG:4326', 'EPSG:3857'); + } + popupOverlay.setPosition(coords); setSelectedSourceData(sourceData); }); }); diff --git a/src/configs/mapSettings.ts b/src/configs/mapSettings.ts index 19db8ff..4a8f910 100644 --- a/src/configs/mapSettings.ts +++ b/src/configs/mapSettings.ts @@ -1,9 +1,12 @@ +import { transformExtent } from 'ol/proj'; +import { ExternalBaselayer } from '../types/maps'; + // Uses a user-defined VITE_SERVICE_URL environment variable for development; otherwise // uses the window.location object's href string, minus the trailing forward slash export const SERVICE_URL: string = import.meta.env.VITE_SERVICE_URL || window.location.href.slice(0, -1); -export const DEFAULT_MAP_SETTINGS = { +export const DEFAULT_INTERNAL_MAP_SETTINGS = { projection: 'EPSG:4326', center: [0, 0], zoom: 0, @@ -12,5 +15,26 @@ export const DEFAULT_MAP_SETTINGS = { multiWorld: true, }; +const MERCATOR_MAX_LAT = 85.0511287798066; + +export const EXTERNAL_BASELAYERS: ExternalBaselayer[] = [ + { + id: 'external-unwise-neo4', + name: 'Legacy Survey | unWISE neo4', + projection: 'EPSG:3857', + url: 'http://imagine.legacysurvey.org/static/tiles/unwise-neo4/1/{z}/{x}/{y}.jpg', + extent: transformExtent( + [-180, -MERCATOR_MAX_LAT, 180, MERCATOR_MAX_LAT], + 'EPSG:4326', + 'EPSG:3857' + ), + // url: function ([z, x, y]) { + // const tileX = (x + 2 ** z) % (2 ** z); // wrap-around X at 360° RA + // // const tileX = ((x + 2) ** z) % (2 ** z); // wrap-around X at 360° RA + // return `http://imagine.legacysurvey.org/static/tiles/unwise-neo4/1/${z}/${tileX}/${y}.jpg`; + // }, + }, +]; + // related to controls export const NUMBER_OF_FIXED_COORDINATE_DECIMALS = 5; diff --git a/src/index.css b/src/index.css index 09831e3..f4667ff 100644 --- a/src/index.css +++ b/src/index.css @@ -42,7 +42,6 @@ body { .scale-control { font-size: 1.3em; - padding: 1px 0 1px 8px; border: 2px solid rgba(0, 0, 0, 0.3); border-top: none; right: 3em; @@ -50,6 +49,12 @@ body { position: absolute; background-color: rgba(255, 255, 255, 0.7); box-shadow: 0 0 5px #bbb; + box-sizing: border-box; +} + +.scale-control > div { + box-sizing: border-box; + padding-left: 8px; } .source-markers { diff --git a/src/reducers/baselayerReducer.ts b/src/reducers/baselayerReducer.ts index 9487317..3eb4640 100644 --- a/src/reducers/baselayerReducer.ts +++ b/src/reducers/baselayerReducer.ts @@ -1,4 +1,16 @@ -import { Band, BaselayerState } from '../types/maps'; +import { Band, BaselayerState, ExternalBaselayer } from '../types/maps'; + +export function assertExternalBaselayer( + baselayer: Band | ExternalBaselayer | undefined +): baselayer is ExternalBaselayer { + return baselayer !== undefined && 'url' in baselayer; +} + +export function assertBand( + baselayer: Band | ExternalBaselayer | undefined +): baselayer is Band { + return baselayer !== undefined && 'map_id' in baselayer; +} export const initialBaselayerState: BaselayerState = { activeBaselayer: undefined, @@ -25,7 +37,7 @@ type ChangeCmapValuesAction = { type ChangeBaselayer = { type: typeof CHANGE_BASELAYER; - newBaselayer: Band; + newBaselayer: Band | ExternalBaselayer; }; type Action = ChangeCmapAction | ChangeCmapValuesAction | ChangeBaselayer; @@ -49,27 +61,46 @@ export function baselayerReducer(state: BaselayerState, action: Action) { } case 'CHANGE_BASELAYER': { const { newBaselayer } = action; - /** - * If we've yet to set an activeBaselayer or the units change between baselayers, - * we need to update the state of cmap properties to the band's recommended values - */ - if ( - !state.activeBaselayer || - newBaselayer.units !== state.activeBaselayer.units - ) { + + if (assertExternalBaselayer(newBaselayer)) { return { - cmap: newBaselayer.recommended_cmap, - cmapValues: { - min: newBaselayer.recommended_cmap_min, - max: newBaselayer.recommended_cmap_max, - }, + cmap: undefined, + cmapValues: undefined, activeBaselayer: newBaselayer, }; - } else { + } + + if (assertBand(newBaselayer)) { /** - * Since units aren't changing and we have an already-defined activeBaselayer, - * simply set a new activeBaselayer + * If we've yet to set an activeBaselayer or we're switching from ExternalBaselayer to a + * Band or the units change between Band baselayers, we need to update the state of cmap + * properties to the band's recommended values */ + if ( + !state.activeBaselayer || + assertExternalBaselayer(state.activeBaselayer) || + newBaselayer.units !== state.activeBaselayer.units + ) { + return { + cmap: newBaselayer.recommended_cmap, + cmapValues: { + min: newBaselayer.recommended_cmap_min, + max: newBaselayer.recommended_cmap_max, + }, + activeBaselayer: newBaselayer, + }; + } else { + /** + * Since units aren't changing and we have an already-defined activeBaselayer, + * simply set a new activeBaselayer + */ + return { + ...state, + activeBaselayer: action.newBaselayer, + }; + } + } else { + /** Fallback to only updating activeBaselayer */ return { ...state, activeBaselayer: action.newBaselayer, diff --git a/src/types/maps.ts b/src/types/maps.ts index 04c2288..273206e 100644 --- a/src/types/maps.ts +++ b/src/types/maps.ts @@ -83,9 +83,19 @@ export type Box = { bottom_right_dec: number; }; +type TileUrlFunction = (x: number[]) => string; + +export type ExternalBaselayer = { + id: string; + name: string; + projection: string; + url: string | TileUrlFunction; + extent: number[]; +}; + export type BaselayerState = { /** the active baselayer selected in the map's legend */ - activeBaselayer?: Band; + activeBaselayer?: Band | ExternalBaselayer; /** the cmap matplotlib parameters used in the histogram components and tile request */ cmap?: string; /** values for vmin and vmax matplotlib parameters used in histogram components and tile requests */ diff --git a/src/utils/layerUtils.ts b/src/utils/layerUtils.ts index eed8afc..7158f00 100644 --- a/src/utils/layerUtils.ts +++ b/src/utils/layerUtils.ts @@ -26,3 +26,16 @@ export function handleSelectChange( ); } } + +export function getBaselayerResolutions( + worldWidth: number, + tileSize: number, + maxZoom: number +) { + const resolutionZ0 = worldWidth / tileSize; + const resolutions = []; + for (let i = 0; i < maxZoom; i++) { + resolutions.push(resolutionZ0 / 2 ** i); + } + return resolutions; +}