Skip to content

Commit 16de841

Browse files
committed
WIP: Add VEDA data layers with layer selection panel
1 parent 9f89cab commit 16de841

File tree

8 files changed

+377
-42
lines changed

8 files changed

+377
-42
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import type { VedaLayer } from '../data/fetchVedaLayers';
3+
4+
interface VedaLayerSelectorProps {
5+
value: string[];
6+
onChange: (layerIds: string[]) => void;
7+
layers: VedaLayer[];
8+
}
9+
10+
export const VedaLayerSelector: React.FC<VedaLayerSelectorProps> = ({
11+
value,
12+
onChange,
13+
layers
14+
}) => {
15+
return (
16+
<div
17+
role='list'
18+
className='max-h-72 overflow-y-auto rounded border border-gray-500'
19+
>
20+
{layers.map((layer) => {
21+
const checked = value.includes(layer.id);
22+
return (
23+
<label
24+
key={layer.id}
25+
className='mb-1 block cursor-pointer rounded px-2 py-1 transition-colors hover:bg-gray-800'
26+
>
27+
<input
28+
type='checkbox'
29+
name='veda-layer'
30+
value={layer.id}
31+
checked={checked}
32+
onChange={() => {
33+
if (checked) {
34+
onChange(value.filter((id) => id !== layer.id));
35+
} else {
36+
onChange([...value, layer.id]);
37+
}
38+
}}
39+
className='mr-2 accent-blue-600'
40+
/>
41+
<span className={checked ? 'font-bold' : 'font-normal'}>
42+
{layer.title}
43+
</span>
44+
{layer.description && (
45+
<p className='ml-2 text-xs text-gray-500'>{layer.description}</p>
46+
)}
47+
</label>
48+
);
49+
})}
50+
</div>
51+
);
52+
};

src/data/fetchVedaLayers.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
export interface VedaLayer {
2+
id: string;
3+
title: string;
4+
description?: string;
5+
renders?: {
6+
dashboard?: {
7+
assets?: string[];
8+
bidx?: number[];
9+
colormap_name?: string;
10+
resampling?: string;
11+
rescale?: [number, number] | number[];
12+
title?: string;
13+
[key: string]: unknown;
14+
};
15+
[key: string]: unknown;
16+
};
17+
}
18+
19+
export interface VedaGetUrl {
20+
(z: number, x: number, y: number): string;
21+
}
22+
23+
// Registers a raster search for a collection and returns the tile URL
24+
interface RasterSearchResponse {
25+
links: { rel: string; href: string }[];
26+
[key: string]: unknown;
27+
}
28+
29+
function isRasterSearchResponse(data: unknown): data is RasterSearchResponse {
30+
return (
31+
typeof data === 'object' &&
32+
data !== null &&
33+
Array.isArray((data as RasterSearchResponse).links) &&
34+
(data as RasterSearchResponse).links.length > 1 &&
35+
typeof (data as RasterSearchResponse).links[1].href === 'string'
36+
);
37+
}
38+
39+
function rendersToApiParams(renders?: VedaLayer['renders']): {
40+
assets?: string[];
41+
bidx?: number[];
42+
colormap_name?: string;
43+
rescale?: string;
44+
[key: string]: unknown;
45+
} {
46+
const dash = renders?.dashboard;
47+
const params: {
48+
assets?: string[];
49+
bidx?: number[];
50+
colormap_name?: string;
51+
rescale?: string;
52+
[key: string]: unknown;
53+
} = {};
54+
if (!dash) return params;
55+
if (dash.assets) params.assets = dash.assets;
56+
if (dash.bidx) params.bidx = dash.bidx;
57+
if (dash.colormap_name) params.colormap_name = dash.colormap_name;
58+
if (dash.rescale)
59+
params.rescale = Array.isArray(dash.rescale)
60+
? dash.rescale.join(',')
61+
: String(dash.rescale);
62+
// Add more as needed
63+
return params;
64+
}
65+
66+
function rendersToQuery(renders?: VedaLayer['renders']): string {
67+
const dash = renders?.dashboard;
68+
if (!dash) return '';
69+
const params = new URLSearchParams();
70+
if (dash.assets) params.set('assets', dash.assets.join(','));
71+
if (dash.bidx) params.set('bidx', dash.bidx.join(','));
72+
if (dash.colormap_name) params.set('colormap_name', dash.colormap_name);
73+
if (dash.rescale)
74+
params.set(
75+
'rescale',
76+
Array.isArray(dash.rescale)
77+
? dash.rescale.join(',')
78+
: String(dash.rescale)
79+
);
80+
// Add more as needed
81+
return params.toString();
82+
}
83+
84+
export async function registerVedaRasterSearch(
85+
collectionId: string,
86+
renders?: VedaLayer['renders']
87+
): Promise<VedaGetUrl> {
88+
const payload = {
89+
'filter-lang': 'cql2-json',
90+
filter: {},
91+
collections: [collectionId],
92+
...rendersToApiParams(renders)
93+
};
94+
const response = await fetch(
95+
'https://openveda.cloud/api/raster/searches/register',
96+
{
97+
method: 'POST',
98+
headers: { 'Content-Type': 'application/json' },
99+
body: JSON.stringify(payload)
100+
}
101+
);
102+
if (!response.ok) {
103+
throw new Error(`Failed to register raster search: ${response.status}`);
104+
}
105+
const data: unknown = await response.json();
106+
if (isRasterSearchResponse(data)) {
107+
const id = typeof data.id === 'string' ? data.id : '';
108+
return (z: number, x: number, y: number) => {
109+
const query = rendersToQuery(renders);
110+
return `https://openveda.cloud/api/raster/searches/${id}/tiles/WebMercatorQuad/${z}/${x}/${y}${query ? `?${query}` : ''}`;
111+
};
112+
}
113+
throw new Error('Could not get tile URL from raster search response');
114+
}
115+
116+
function isVedaLayer(obj: unknown): obj is VedaLayer {
117+
return (
118+
typeof obj === 'object' &&
119+
obj !== null &&
120+
typeof (obj as { id?: unknown }).id === 'string' &&
121+
typeof (obj as { title?: unknown }).title === 'string'
122+
);
123+
}
124+
125+
interface StacCollectionsResponse {
126+
collections: unknown[];
127+
}
128+
129+
function isStacCollectionsResponse(
130+
obj: unknown
131+
): obj is StacCollectionsResponse {
132+
return (
133+
typeof obj === 'object' &&
134+
obj !== null &&
135+
Array.isArray((obj as { collections?: unknown }).collections)
136+
);
137+
}
138+
139+
export async function fetchVedaLayers(): Promise<VedaLayer[]> {
140+
const response = await fetch('https://openveda.cloud/api/stac/collections');
141+
if (!response.ok) {
142+
throw new Error(`Failed to fetch VEDA layers: ${response.status}`);
143+
}
144+
const data: unknown = await response.json();
145+
if (!isStacCollectionsResponse(data)) {
146+
throw new Error('VEDA STAC API did not return a collections array');
147+
}
148+
const validLayers = data.collections.filter(isVedaLayer);
149+
console.log('Fetched VEDA layers:', validLayers);
150+
return validLayers.map((item) => ({
151+
id: item.id,
152+
title: item.title,
153+
description:
154+
typeof item.description === 'string' ? item.description : undefined,
155+
renders: item.renders
156+
}));
157+
}
158+
159+
export async function getVedaLayerTileUrl(
160+
collectionId: string,
161+
renders?: VedaLayer['renders']
162+
): Promise<VedaGetUrl> {
163+
return registerVedaRasterSearch(collectionId, renders);
164+
}

src/data/tileUrls.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// TODO: To be replaced with the veda data catalog: https://github.com/NASA-IMPACT/veda-config/tree/develop/datasets
2+
3+
export const tileUrls = [
4+
{
5+
id: 'ef5766e5684b02f6bf65185f542354f3',
6+
name: 'Mean Carbon Dioxide',
7+
getUrl: (z: number, x: number, y: number) =>
8+
`https://openveda.cloud/api/raster/searches/ef5766e5684b02f6bf65185f542354f3/tiles/WebMercatorQuad/${z}/${x}/${y}?title=Mean-Carbon-Dioxide&rescale=0.000408%2C0.000419&colormap_name=rdylbu_r&reScale=NaN%2CNaN&assets=cog_default`
9+
}
10+
];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { VedaLayerSelector } from '../components/VedaLayerSelector';
2+
import type { VedaLayer } from '../data/fetchVedaLayers';
3+
4+
export function DeckGlobeOverlayLayerSelector({
5+
value,
6+
onChange,
7+
layers
8+
}: {
9+
value: string[];
10+
onChange: (layerIds: string[]) => void;
11+
layers: VedaLayer[];
12+
}) {
13+
return (
14+
<div className='fixed top-50 left-4 z-50 w-80 max-w-xs rounded-lg bg-gray-900 p-4 shadow-lg ring-1 ring-black/10 backdrop-blur-md'>
15+
<h2 className='mb-2 text-base font-semibold text-gray-200'>
16+
Select VEDA Layer
17+
</h2>
18+
<VedaLayerSelector value={value} onChange={onChange} layers={layers} />
19+
</div>
20+
);
21+
}

src/deckgl/contexts/GlobeContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface DeckGLGlobeContextType {
1919
}
2020

2121
export const DeckGLGlobeContext = createContext<DeckGLGlobeContextType>({
22-
rotationState: 'rotating',
22+
rotationState: 'stopped',
2323
setRotationState: () => {},
2424
viewState: INITIAL_VIEW_STATE,
2525
setViewState: () => {},

src/deckgl/contexts/GlobeProvider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
export const DeckGLGlobeProvider: React.FC<{ children: ReactNode }> = ({
1010
children
1111
}) => {
12-
const [rotationState, setRotationState] = useState<RotationState>('rotating');
12+
const [rotationState, setRotationState] = useState<RotationState>('stopped');
1313
const [viewState, setViewState] =
1414
useState<GlobeViewState>(INITIAL_VIEW_STATE);
1515
const [isInteracting, setIsInteracting] = useState(false);

src/deckgl/index.tsx

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ import type { GlobeViewState, PickingInfo } from '@deck.gl/core';
1414
import { DeckGLGlobeContext } from './contexts/GlobeContext';
1515
import { DeckGLGlobeProvider } from './contexts/GlobeProvider';
1616
import { RotationControlButton } from '../components/RotationControlButton';
17-
import { backgroundLayers, dataLayers } from './layers';
17+
import { DeckGlobeOverlayLayerSelector } from './DeckGlobeOverlayLayerSelector';
18+
import {
19+
backgroundLayers,
20+
dataLayers,
21+
markerLayers,
22+
type VedaTileLayer
23+
} from './layers';
24+
import { fetchVedaLayers, getVedaLayerTileUrl } from '../data/fetchVedaLayers';
25+
import type { VedaLayer } from '../data/fetchVedaLayers';
1826
import { lightingEffect } from './lighting';
1927
import type { MarkerData } from './types';
2028
import { getTooltip } from './tooltip';
@@ -36,6 +44,23 @@ function DeckGLGlobeInner() {
3644
// Track if a marker is hovered
3745
const [isMarkerHovered, setIsMarkerHovered] = useState(false);
3846

47+
// VEDA layer selection state
48+
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
49+
const [allVedaCollections, setAllVedaCollections] = useState<VedaLayer[]>([]);
50+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
51+
const [vedaError, setVedaError] = useState<string | null>(null);
52+
53+
// Fetch all VEDA layers on mount
54+
useEffect(() => {
55+
fetchVedaLayers()
56+
.then((layers) => {
57+
setAllVedaCollections(layers);
58+
})
59+
.catch((err) => {
60+
setVedaError(err instanceof Error ? err.message : String(err));
61+
});
62+
}, []);
63+
3964
// Auto-rotation logic
4065
useEffect(() => {
4166
const animate = (time: number) => {
@@ -146,21 +171,70 @@ function DeckGLGlobeInner() {
146171

147172
const memoizedBackgroundLayers = useMemo(() => backgroundLayers, []);
148173

174+
// Store selected VEDA tile layers in state
175+
const [selectedTileLayers, setSelectedTileLayers] = useState<VedaTileLayer[]>(
176+
[]
177+
);
178+
179+
useEffect(() => {
180+
let isMounted = true;
181+
const fetchTileLayers = async () => {
182+
const layers = await Promise.all(
183+
selectedLayerIds.map(async (id) => {
184+
const layer = allVedaCollections.find((l) => l.id === id);
185+
if (layer) {
186+
console.log(layer);
187+
const urlFunc = await getVedaLayerTileUrl(layer.id, layer.renders);
188+
console.log(urlFunc);
189+
return {
190+
...layer,
191+
getUrl: urlFunc
192+
} as unknown as VedaTileLayer;
193+
}
194+
return undefined;
195+
})
196+
);
197+
if (isMounted) {
198+
setSelectedTileLayers(
199+
layers.filter((x): x is NonNullable<typeof x> => x !== undefined)
200+
);
201+
}
202+
};
203+
void fetchTileLayers();
204+
return () => {
205+
isMounted = false;
206+
};
207+
}, [selectedLayerIds, allVedaCollections]);
208+
149209
const memoizedDataLayers = useMemo(
150-
() => dataLayers(handleMarkerClick, rotationState, setIsMarkerHovered),
210+
() => dataLayers(selectedTileLayers),
211+
[selectedTileLayers]
212+
);
213+
214+
const memoizedMarkerLayers = useMemo(
215+
() => markerLayers(handleMarkerClick, rotationState, setIsMarkerHovered),
151216
[handleMarkerClick, rotationState, setIsMarkerHovered]
152217
);
153218

154219
return (
155220
<>
221+
<DeckGlobeOverlayLayerSelector
222+
value={selectedLayerIds}
223+
onChange={setSelectedLayerIds}
224+
layers={allVedaCollections}
225+
/>
156226
<DeckGL
157227
views={new GlobeView()}
158228
viewState={viewState}
159229
onViewStateChange={handleViewStateChange}
160230
onInteractionStateChange={handleInteractionStateChange}
161231
controller={true}
162232
effects={[lightingEffect]}
163-
layers={[...memoizedBackgroundLayers, ...memoizedDataLayers]}
233+
layers={[
234+
...memoizedBackgroundLayers,
235+
...memoizedDataLayers,
236+
...memoizedMarkerLayers
237+
]}
164238
getCursor={({ isDragging }) =>
165239
isMarkerHovered ? 'pointer' : isDragging ? 'grabbing' : 'grab'
166240
}

0 commit comments

Comments
 (0)