(null);
+
+ const handleSourceClick = useCallback(
+ (e: SelectEvent) => {
+ const select = selectInteractionRef.current;
+ const popupOverlay = popupOverlayRef.current;
+ if (!select || !popupOverlay) return;
+ const selectedFeatures = e.selected;
+ if (selectedFeatures.length === 0) {
+ popupOverlay.setPosition(undefined);
+ setSelectedSourceData(undefined);
+ return;
+ }
+ selectedFeatures.forEach((feature) => {
+ const { newOverlayCoords, newSourceData } = transformSources(
+ feature,
+ flipped
+ );
+ popupOverlay.setPosition(newOverlayCoords);
+ setSelectedSourceData(newSourceData);
+ });
+ },
+ [flipped]
+ );
+
+ // Create/reuse the source group layer
+ useEffect(() => {
+ if (!mapRef.current) return;
+
+ const map = mapRef.current;
+
+ // Clean up old layer if needed
+ if (sourceGroupRef.current) {
+ map.removeLayer(sourceGroupRef.current);
+ }
+
+ const newLayers = sourceLists
.filter((sl) => activeSourceListIds.includes(sl.id))
- .map(
- (sl) =>
- new LayerGroup({
- properties: {
- id: 'sourcelist-group-' + sl.id,
- },
- layers: [
- new VectorLayer({
- source: new VectorSource({
- features: sl.sources.map(
- (source) =>
- new Feature({
- geometry: new Circle([source.ra, source.dec], 1),
- sourceData: source,
- })
- ),
- }),
- style: {
- 'stroke-width': 2,
- 'stroke-color': '#3388FF',
- 'fill-color': [51, 136, 255, 0.2],
- },
- }),
- ],
- zIndex: 500,
- })
- );
+ .map((sl) => {
+ return new VectorLayer({
+ source: new VectorSource({
+ features: sl.sources.map((source) => {
+ const coords = [source.ra, source.dec];
+ return new Feature({
+ geometry: new Circle(coords, 1),
+ sourceData: source,
+ });
+ }),
+ wrapX: false,
+ }),
+ style: {
+ 'stroke-width': 2,
+ 'stroke-color': '#3388FF',
+ 'fill-color': [51, 136, 255, 0.2],
+ },
+ });
+ });
+
+ const group = new LayerGroup({
+ layers: newLayers,
+ properties: { id: 'sourcelist-group' },
+ zIndex: 500,
+ });
+
+ sourceGroupRef.current = group;
+ map.addLayer(group);
}, [sourceLists, activeSourceListIds]);
+ // Set up interaction and popup
useEffect(() => {
- if (!mapRef.current) return;
- mapRef.current.getLayers().forEach((l) => {
- const id = l.get('id') as string;
- if (typeof id === 'string' && id.includes('sourcelist-group')) {
- l.setVisible(false);
- }
+ const map = mapRef.current;
+ if (!map || !popupRef.current) return;
+
+ // Add popup overlay
+ const popupOverlay = new Overlay({
+ element: popupRef.current,
});
- sourceOverlays.forEach((so) => {
- mapRef.current?.addLayer(so);
+ popupOverlayRef.current = popupOverlay;
+ map.addOverlay(popupOverlay);
+
+ // Set up click interaction
+ const select = new Select({
+ condition: click,
+ layers: (layer) => {
+ const group = sourceGroupRef.current;
+ return group ? group.getLayers().getArray().includes(layer) : false;
+ },
});
- }, [sourceOverlays]);
+
+ map.addInteraction(select);
+ selectInteractionRef.current = select;
+
+ return () => {
+ map.removeOverlay(popupOverlay);
+ map.removeInteraction(select);
+ };
+ }, []);
useEffect(() => {
- if (!mapRef.current) return;
- if (popupRef.current) {
- const popupOverlay = new Overlay({
- element: popupRef.current,
- });
- mapRef.current.addOverlay(popupOverlay);
- const select = new Select({
- condition: click,
- layers: (layer) => {
- return sourceOverlays.some((group) =>
- group.getLayers().getArray().includes(layer)
- );
- },
- });
- mapRef.current.addInteraction(select);
- select.on('select', (e) => {
- const selectedFeatures = e.selected;
-
- if (selectedFeatures.length === 0) {
- // user clicked on empty space, so clear popup data
- popupOverlay.setPosition(undefined);
- setSelectedSourceData(undefined);
- return;
- }
-
- selectedFeatures.forEach((feature) => {
- const sourceData = feature.get('sourceData') as Source;
- popupOverlay.setPosition([sourceData.ra, sourceData.dec]);
- setSelectedSourceData(sourceData);
- });
- });
+ if (mapRef.current) {
+ if (handleSourceClickRef.current) {
+ selectInteractionRef.current?.un(
+ 'select',
+ handleSourceClickRef.current
+ );
+ }
+ handleSourceClickRef.current = handleSourceClick;
+ selectInteractionRef.current?.on('select', handleSourceClick);
}
- }, [mapRef.current, popupRef.current, sourceOverlays, activeSourceListIds]);
+ }, [handleSourceClick]);
+
+ useEffect(() => {
+ const map = mapRef.current;
+ const sourceGroup = sourceGroupRef.current;
+ if (!sourceGroup || !map) return;
+ sourceGroup.getLayers().forEach((l) => {
+ const source = (l as VectorLayer).getSource();
+ if (source instanceof VectorSource) {
+ source.getFeatures().forEach((f: Feature) => {
+ if (f) {
+ const { newOverlayCoords, newSourceData } = transformSources(
+ f,
+ flipped
+ );
+ const circle = f.getGeometry() as Circle;
+ circle.setCenter(newOverlayCoords);
+ if (newSourceData.id === selectedSourceData?.id) {
+ popupOverlayRef.current?.setPosition(newOverlayCoords);
+ setSelectedSourceData(newSourceData);
+ }
+ }
+ });
+ }
+ });
+ }, [flipped]);
return (
diff --git a/src/components/styles/layer-selector.css b/src/components/styles/layer-selector.css
index 841d834..49d1c0c 100644
--- a/src/components/styles/layer-selector.css
+++ b/src/components/styles/layer-selector.css
@@ -30,3 +30,9 @@
height: 40px;
fill: #666;
}
+
+.input-container.disabled > label,
+input[type='radio']:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
diff --git a/src/components/styles/toggle-switch.css b/src/components/styles/toggle-switch.css
new file mode 100644
index 0000000..37ea70b
--- /dev/null
+++ b/src/components/styles/toggle-switch.css
@@ -0,0 +1,88 @@
+.toggle-container {
+ position: absolute;
+ left: 50px;
+ top: 12px;
+ z-index: 5000;
+ background-color: white;
+ border-radius: 4px;
+ padding: 3px;
+ box-shadow: 1px 2px 2px rgba(128, 128, 128, 0.25);
+}
+
+.toggle-container.disabled {
+ cursor: not-allowed;
+}
+
+.label {
+ display: flex;
+ flex-direction: row;
+ font-size: 1em;
+ cursor: pointer;
+}
+
+.label.disabled {
+ cursor: not-allowed;
+ opacity: 0.3;
+}
+
+.input {
+ display: none;
+}
+
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 30px;
+ height: 16px;
+}
+
+.slider {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ -webkit-transition: 0.4s;
+ transition: 0.4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: '';
+ height: 12px;
+ width: 12px;
+ left: 2px;
+ bottom: 1px;
+ background-color: white;
+ -webkit-transition: 0.4s;
+ transition: 0.4s;
+}
+
+input:checked + .label .slider {
+ background-color: black;
+}
+
+input:focus + .label .slider {
+ box-shadow: 0 0 1px black;
+}
+
+input:checked + .label .slider:before {
+ -webkit-transform: translateX(12px);
+ -ms-transform: translateX(12px);
+ transform: translateX(12px);
+}
+
+.slider.round {
+ border-radius: 34px;
+ border: 1px solid darkgray;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+.left,
+.right {
+ margin: 0 0.5em;
+}
diff --git a/src/configs/mapSettings.ts b/src/configs/mapSettings.ts
index 19db8ff..4113ef2 100644
--- a/src/configs/mapSettings.ts
+++ b/src/configs/mapSettings.ts
@@ -1,16 +1,39 @@
+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 = {
+const MERCATOR_MAX_LAT = 85.0511287798066;
+
+export const CAR_BBOX = [-180, -90, 180, 90];
+export const MERCATOR_BBOX = [-180, -MERCATOR_MAX_LAT, 180, MERCATOR_MAX_LAT];
+
+export const DEFAULT_INTERNAL_MAP_SETTINGS = {
projection: 'EPSG:4326',
center: [0, 0],
- zoom: 0,
- extent: [-180, -90, 180, 90],
+ zoom: 2,
+ // extent: [-180, -90, 180, 90],
showFullExtent: true,
multiWorld: true,
};
+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'
+ ),
+ disabledState: (isFlipped: boolean) => !isFlipped,
+ },
+];
+
// 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..fb2e2ed 100644
--- a/src/types/maps.ts
+++ b/src/types/maps.ts
@@ -73,19 +73,33 @@ export interface SourceList extends SourceListResponse {
sources: Source[];
}
-export type Box = {
- id: number;
- name: string;
- description: string;
+export type BoxExtent = {
top_left_ra: number;
top_left_dec: number;
bottom_right_ra: number;
bottom_right_dec: number;
};
+export type Box = BoxExtent & {
+ id: number;
+ name: string;
+ description: string;
+};
+
+type TileUrlFunction = (x: number[]) => string;
+
+export type ExternalBaselayer = {
+ id: string;
+ name: string;
+ projection: string;
+ url: string | TileUrlFunction;
+ extent: number[];
+ disabledState: (state: boolean) => boolean;
+};
+
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/fetchUtils.ts b/src/utils/fetchUtils.ts
index 65cae8d..e218ad3 100644
--- a/src/utils/fetchUtils.ts
+++ b/src/utils/fetchUtils.ts
@@ -9,6 +9,7 @@ import {
SubmapDataWithBounds,
} from '../types/maps';
import { SubmapFileExtensions } from '../configs/submapConfigs';
+import { transformBoxes } from './layerUtils';
type SourcesResponse = {
catalogs: SourceListResponse[];
@@ -107,19 +108,45 @@ export async function addSubmapAsBox(
top_left: number[];
bottom_right: number[];
},
+ flipped: boolean,
setBoxes: (boxes: Box[]) => void,
setActiveBoxIds: React.Dispatch>,
addOptimisticHighlightBox: (action: Box) => void
) {
const { params, top_left, bottom_right } = boxData;
- const endpoint = `${SERVICE_URL}/highlights/boxes/new?${params.toString()}`;
+ let syncedPositionForBackend = {
+ top_left_ra: top_left[0],
+ top_left_dec: top_left[1],
+ bottom_right_ra: bottom_right[0],
+ bottom_right_dec: bottom_right[1],
+ };
+
+ if (flipped) {
+ syncedPositionForBackend = transformBoxes(
+ {
+ top_left_ra: top_left[0],
+ top_left_dec: top_left[1],
+ bottom_right_ra: bottom_right[0],
+ bottom_right_dec: bottom_right[1],
+ },
+ flipped
+ );
+ }
const requestBody = {
- top_left,
- bottom_right,
+ top_left: [
+ syncedPositionForBackend.top_left_ra,
+ syncedPositionForBackend.top_left_dec,
+ ],
+ bottom_right: [
+ syncedPositionForBackend.bottom_right_ra,
+ syncedPositionForBackend.bottom_right_dec,
+ ],
};
+ const endpoint = `${SERVICE_URL}/highlights/boxes/new?${params.toString()}`;
+
try {
const response = await fetch(endpoint, {
method: 'PUT',
diff --git a/src/utils/layerUtils.ts b/src/utils/layerUtils.ts
index eed8afc..e59364a 100644
--- a/src/utils/layerUtils.ts
+++ b/src/utils/layerUtils.ts
@@ -1,4 +1,6 @@
-import { Band } from '../types/maps';
+import { Feature } from 'ol';
+import { Band, BoxExtent } from '../types/maps';
+import { Source } from '../types/maps';
/**
* A utility function to format a layer's name.
@@ -26,3 +28,113 @@ 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;
+}
+
+export function transformGraticuleCoords(
+ coords: number[],
+ isFlipped: boolean
+): number[] {
+ if (isFlipped) {
+ const [ra, dec] = coords;
+ const newRa = ra * -1 + 180;
+ return [newRa, dec];
+ } else {
+ return coords;
+ }
+}
+
+export function transformCoords(
+ coords: number[],
+ isFlipped: boolean,
+ context: 'search' | 'layer'
+): number[] {
+ const [ra, dec] = coords;
+ const newRa = coords[0] * -1 + (coords[0] > 0 ? 180 : -180);
+ if (isFlipped) {
+ if (context === 'search') {
+ return [ra > 0 ? ra : ra + 360, dec];
+ }
+ if (context === 'layer') {
+ return [newRa, dec];
+ }
+ return coords;
+ } else {
+ return [newRa, dec];
+ }
+}
+
+export function transformSources(feature: Feature, flipped: boolean) {
+ const sourceData = feature.get('sourceData') as Source;
+ let newOverlayCoords = [sourceData.ra, sourceData.dec];
+ let newSourceData = { ...sourceData };
+ if (flipped) {
+ newOverlayCoords = transformCoords(
+ [sourceData.ra, sourceData.dec],
+ flipped,
+ 'layer'
+ );
+ newSourceData = {
+ ...sourceData,
+ ra: sourceData.ra < 0 ? sourceData.ra + 360 : sourceData.ra,
+ };
+ }
+ return {
+ newOverlayCoords,
+ newSourceData,
+ };
+}
+
+export function transformBoxes(boxExtent: BoxExtent, flipped: boolean) {
+ let newBoxPosition = {
+ ...boxExtent,
+ };
+
+ if (flipped) {
+ const isLeftRaNegative = boxExtent.top_left_ra < 0;
+ const isRightRaNegative = boxExtent.bottom_right_ra < 0;
+ const newRaLeft = transformCoords(
+ [boxExtent.bottom_right_ra, boxExtent.bottom_right_dec],
+ flipped,
+ 'layer'
+ )[0];
+ if (
+ (isLeftRaNegative && isRightRaNegative) ||
+ (!isLeftRaNegative && !isRightRaNegative)
+ ) {
+ newBoxPosition['top_left_ra'] = newRaLeft;
+ newBoxPosition['bottom_right_ra'] = transformCoords(
+ [boxExtent.top_left_ra, boxExtent.top_left_dec],
+ flipped,
+ 'layer'
+ )[0];
+ } else {
+ const newRaRight =
+ newRaLeft + Math.abs(boxExtent.bottom_right_ra - boxExtent.top_left_ra);
+ newBoxPosition['top_left_ra'] = newRaLeft;
+ newBoxPosition['bottom_right_ra'] = newRaRight;
+ }
+ }
+
+ return newBoxPosition;
+}
+
+export function isBoxSynced(currentData: BoxExtent, originalData: BoxExtent) {
+ return (
+ currentData.top_left_ra === originalData.top_left_ra &&
+ currentData.top_left_dec === originalData.top_left_dec &&
+ currentData.bottom_right_ra === originalData.bottom_right_ra &&
+ currentData.bottom_right_dec === originalData.bottom_right_dec
+ );
+}