diff --git a/docs/conf.py b/docs/conf.py index f183ce9bb..d0dfcf658 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,6 +33,7 @@ "../examples/*.zip", "../examples/*.gif", "../examples/*.geojson", + "../examples/*.tif", ] jupyterlite_dir = "." jupyterlite_config = "jupyter_lite_config.json" diff --git a/examples/geotiff-example.tif b/examples/geotiff-example.tif new file mode 100644 index 000000000..fcd16831e Binary files /dev/null and b/examples/geotiff-example.tif differ diff --git a/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts b/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts index 86f884f60..105d4a104 100644 --- a/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts +++ b/packages/base/src/dialogs/symbology/hooks/useGetBandInfo.ts @@ -1,7 +1,7 @@ import { IDict, IJGISLayer, IJupyterGISModel } from '@jupytergis/schema'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { useEffect, useState } from 'react'; -import { loadGeoTIFFWithCache } from '../../../tools'; +import { loadFile } from '../../../tools'; export interface IBandHistogram { buckets: number[]; @@ -34,8 +34,17 @@ interface ITifBandData { histogram: any; } -const preloadGeoTiffFile = async (sourceInfo: { url?: string | undefined }) => { - return await loadGeoTIFFWithCache(sourceInfo); +const preloadGeoTiffFile = async ( + sourceInfo: { + url?: string | undefined; + }, + model: IJupyterGISModel +): Promise<{ file: Blob; metadata: any; sourceUrl: string }> => { + return await loadFile({ + filepath: sourceInfo.url ?? '', + type: 'GeoTiffSource', + model: model + }); }; const useGetBandInfo = ( @@ -61,7 +70,7 @@ const useGetBandInfo = ( return; } - const preloadedFile = await preloadGeoTiffFile(sourceInfo); + const preloadedFile = await preloadGeoTiffFile(sourceInfo, context.model); const { file, metadata, sourceUrl } = { ...preloadedFile }; if (file && metadata && sourceUrl === sourceInfo.url) { diff --git a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx index 46c03a35f..5aaa19358 100644 --- a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx +++ b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx @@ -45,7 +45,7 @@ export const FileSelectorWidget = (props: any) => { } const output = await FileDialog.getOpenFiles({ - title: 'Select a File', + title: `Select ${formOptions.sourceType.split('Source')[0]} File`, manager: docManager }); @@ -62,10 +62,24 @@ export const FileSelectorWidget = (props: any) => { props.onChange(relativePath); if (dialogElement) { - formOptions.dialogOptions.sourceData = { - ...formOptions.sourceData, - path: relativePath - }; + if (formOptions.sourceType === 'GeoTiffSource') { + formOptions.dialogOptions.sourceData = { + ...formOptions.sourceData, + urls: formOptions.dialogOptions.sourceData.urls.map( + (urlObject: any) => { + return { + ...urlObject, + url: relativePath + }; + } + ) + }; + } else { + formOptions.dialogOptions.sourceData = { + ...formOptions.sourceData, + path: relativePath + }; + } const formDialog = new CreationFormDialog({ ...formOptions.dialogOptions diff --git a/packages/base/src/formbuilder/objectform/geotiffsource.ts b/packages/base/src/formbuilder/objectform/geotiffsource.ts index 80e1cfad4..5ca3044f9 100644 --- a/packages/base/src/formbuilder/objectform/geotiffsource.ts +++ b/packages/base/src/formbuilder/objectform/geotiffsource.ts @@ -3,16 +3,56 @@ import { showErrorMessage } from '@jupyterlab/apputils'; import { IChangeEvent, ISubmitEvent } from '@rjsf/core'; import { BaseForm, IBaseFormProps } from './baseform'; +import { FileSelectorWidget } from './fileselectorwidget'; +import { getMimeType } from '../../tools'; /** * The form to modify a GeoTiff source. */ export class GeoTiffSourcePropertiesForm extends BaseForm { + private _isSubmitted: boolean; + constructor(props: IBaseFormProps) { super(props); + + this._isSubmitted = false; this._validateUrls(props.sourceData?.urls ?? []); } + protected processSchema( + data: IDict | undefined, + schema: IDict, + uiSchema: IDict + ) { + super.processSchema(data, schema, uiSchema); + if (!schema.properties || !data) { + return; + } + + // Customize the widget for urls + if (schema.properties && schema.properties.urls) { + const docManager = + this.props.formChangedSignal?.sender.props.formSchemaRegistry.getDocManager(); + + uiSchema.urls = { + ...uiSchema.urls, + items: { + ...uiSchema.urls.items, + url: { + 'ui:widget': FileSelectorWidget, + 'ui:options': { + docManager, + formOptions: this.props + } + } + } + }; + } + + // This is not user-editable + delete schema.properties.valid; + } + protected onFormChange(e: IChangeEvent): void { super.onFormChange(e); if (e.formData?.urls) { @@ -20,9 +60,23 @@ export class GeoTiffSourcePropertiesForm extends BaseForm { } } - protected onFormSubmit(e: ISubmitEvent) { - if (this.state.extraErrors?.urls?.__errors?.length >= 1) { - showErrorMessage('Invalid URLs', this.state.extraErrors.urls.__errors[0]); + protected onFormBlur(id: string, value: any) { + // Is there a better way to spot the url text entry? + if (!id.endsWith('_urls')) { + return; + } + this._validateUrls(value); + } + + protected async onFormSubmit(e: ISubmitEvent) { + this._isSubmitted = true; + + // validate urls.url only when submitting for better performance + const { valid, errors } = await this._validateUrls(e.formData.urls); + if (!valid) { + if (errors.length > 0) { + showErrorMessage('Invalid URLs', errors[0]); + } return; } super.onFormSubmit(e); @@ -41,31 +95,44 @@ export class GeoTiffSourcePropertiesForm extends BaseForm { if (urls && urls.length > 0) { for (let i = 0; i < urls.length; i++) { const { url, min, max } = urls[i]; + if (this._isSubmitted) { + const mimeType = getMimeType(url); + if (!mimeType || !mimeType.startsWith('image/tiff')) { + valid = false; + errors.push( + `"${url}" is not a valid ${this.props.sourceType} file.` + ); + } + } else { + if (!url || typeof url !== 'string' || url.trim() === '') { + valid = false; + errors.push( + `URL at index ${i} is required and must be a valid string.` + ); + } - if (!url || typeof url !== 'string' || url.trim() === '') { - errors.push( - `URL at index ${i} is required and must be a valid string.` - ); - valid = false; - } + if (min === undefined || typeof min !== 'number') { + errors.push( + `Min value at index ${i} is required and must be a number.` + ); + valid = false; + } - if (min === undefined || typeof min !== 'number') { - errors.push( - `Min value at index ${i} is required and must be a number.` - ); - valid = false; - } - - if (max === undefined || typeof max !== 'number') { - errors.push( - `Max value at index ${i} is required and must be a number.` - ); - valid = false; - } + if (max === undefined || typeof max !== 'number') { + errors.push( + `Max value at index ${i} is required and must be a number.` + ); + valid = false; + } - if (typeof min === 'number' && typeof max === 'number' && max <= min) { - errors.push(`Max value at index ${i} must be greater than Min.`); - valid = false; + if ( + typeof min === 'number' && + typeof max === 'number' && + max <= min + ) { + errors.push(`Max value at index ${i} must be greater than Min.`); + valid = false; + } } } } else { @@ -88,5 +155,6 @@ export class GeoTiffSourcePropertiesForm extends BaseForm { if (this.props.formErrorSignal) { this.props.formErrorSignal.emit(!valid); } + return { valid, errors }; } } diff --git a/packages/base/src/mainview/mainView.tsx b/packages/base/src/mainview/mainView.tsx index 08b7ed4db..617b8cf6b 100644 --- a/packages/base/src/mainview/mainView.tsx +++ b/packages/base/src/mainview/mainView.tsx @@ -75,12 +75,7 @@ import * as React from 'react'; import AnnotationFloater from '../annotations/components/AnnotationFloater'; import { CommandIDs } from '../constants'; import StatusBar from '../statusbar/StatusBar'; -import { - isLightTheme, - loadFile, - loadGeoTIFFWithCache, - throttle -} from '../tools'; +import { isLightTheme, loadFile, throttle } from '../tools'; import CollaboratorPointers, { ClientPointer } from './CollaboratorPointers'; import { FollowIndicator } from './FollowIndicator'; import { MainViewModel } from './mainviewmodel'; @@ -451,13 +446,6 @@ export class MainView extends React.Component { }); }; - private async _loadGeoTIFFWithCache(sourceInfo: { - url?: string | undefined; - }) { - const result = await loadGeoTIFFWithCache(sourceInfo); - return result?.file; - } - /** * Add a source in the map. * @@ -639,8 +627,16 @@ export class MainView extends React.Component { }; const sourcesWithBlobs = await Promise.all( sourceParameters.urls.map(async sourceInfo => { - const blob = await this._loadGeoTIFFWithCache(sourceInfo); - return { ...addNoData(sourceInfo), blob }; + const geotiff = await loadFile({ + filepath: sourceInfo.url ?? '', + type: 'GeoTiffSource', + model: this._model + }); + return { + ...addNoData(sourceInfo), + geotiff, + url: URL.createObjectURL(geotiff.file) + }; }) ); diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index 16539e025..878a50358 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -3,7 +3,7 @@ import Protobuf from 'pbf'; import { VectorTile } from '@mapbox/vector-tile'; import { URLExt } from '@jupyterlab/coreutils'; -import { ServerConnection } from '@jupyterlab/services'; +import { Contents, ServerConnection } from '@jupyterlab/services'; import * as d3Color from 'd3-color'; import { PathExt } from '@jupyterlab/coreutils'; import shp from 'shpjs'; @@ -428,13 +428,21 @@ export const getFromIndexedDB = async (key: string) => { * @param sourceInfo object containing the URL of the GeoTIFF file. * @returns A promise that resolves to the file as a Blob, or undefined . */ -export const loadGeoTIFFWithCache = async (sourceInfo: { - url?: string | undefined; -}) => { +export const loadGeoTiff = async ( + sourceInfo: { + url?: string | undefined; + }, + file?: Contents.IModel | null +) => { if (!sourceInfo?.url) { return null; } + const mimeType = getMimeType(sourceInfo.url); + if (!mimeType || !mimeType.startsWith('image/tiff')) { + throw new Error('Invalid file type. Expected GeoTIFF (image/tiff).'); + } + const cachedData = await getFromIndexedDB(sourceInfo.url); if (cachedData) { return { @@ -444,12 +452,23 @@ export const loadGeoTIFFWithCache = async (sourceInfo: { }; } - const response = await fetch(sourceInfo.url); - const fileBlob = await response.blob(); - const file = new File([fileBlob], 'loaded.tif'); + let fileBlob: Blob; + if (!file) { + const response = await fetch( + `/jupytergis_core/proxy?url=${sourceInfo.url}` + ); + if (!response.ok) { + throw new Error(`Failed to fetch file. Status: ${response.status}`); + } + fileBlob = await response.blob(); + } else { + fileBlob = await base64ToBlob(file.content, mimeType); + } + + const geotiff = new File([fileBlob], 'loaded.tif'); const Gdal = await getGdal(); - const result = await Gdal.open(file); + const result = await Gdal.open(geotiff); const tifDataset = result.datasets[0]; const metadata = await Gdal.gdalinfo(tifDataset, ['-stats']); Gdal.close(tifDataset); @@ -541,6 +560,16 @@ export const loadFile = async (fileInfo: { } } + case 'GeoTiffSource': { + try { + const tiff = loadGeoTiff({ url: filepath }); + return tiff; + } catch (error) { + console.error('Error loading remote GeoTIFF:', error); + throw error; + } + } + default: { throw new Error(`Unsupported URL handling for source type: ${type}`); } @@ -598,6 +627,15 @@ export const loadFile = async (fileInfo: { } } + case 'GeoTiffSource': { + if (typeof file.content === 'string') { + const tiff = loadGeoTiff({ url: filepath }, file); + return tiff; + } else { + throw new Error('Invalid file format for tiff content.'); + } + } + default: { throw new Error(`Unsupported source type: ${type}`); }