diff --git a/examples/world.jGIS b/examples/world.jGIS index 89b6c2e9d..8987187a0 100644 --- a/examples/world.jGIS +++ b/examples/world.jGIS @@ -1,10 +1,11 @@ { "layerTree": [ - "6e55cdae-35b0-4bff-9dd1-c8aa9563b2a6" + "6e55cdae-35b0-4bff-9dd1-c8aa9563b2a6", + "f80d0fa2-3e2b-4922-b7d5-fefd4b085259" ], "layers": { "6e55cdae-35b0-4bff-9dd1-c8aa9563b2a6": { - "name": "Custom GeoJSON Layer", + "name": "World", "parameters": { "color": { "fill-color": [ @@ -95,26 +96,53 @@ }, "type": "VectorLayer", "visible": true + }, + "f80d0fa2-3e2b-4922-b7d5-fefd4b085259": { + "name": "France", + "parameters": { + "color": { + "fill-color": "#ff0000", + "stroke-color": "#3399CC", + "stroke-line-cap": "round", + "stroke-line-join": "round", + "stroke-width": 1.25 + }, + "opacity": 1.0, + "source": "5970d6c9-26be-4cc6-84c9-16593dd3edfe", + "symbologyState": { + "renderType": "Single Symbol" + }, + "type": "line" + }, + "type": "VectorLayer", + "visible": true } }, "metadata": {}, "options": { "bearing": 0.0, "extent": [ - -35938860.79774074, - -17466155.24107265, - 4136155.887837734, - 18813049.299423713 + -19065470.770224877, + -18193969.787702393, + 21009545.91535361, + 20037508.342789244 ], - "latitude": 6.038467945870664, - "longitude": -142.8442794845441, + "latitude": 8.251719751227498, + "longitude": 8.731962081730105, "pitch": 0.0, "projection": "EPSG:3857", - "zoom": 2.100662339005199 + "zoom": 1.834471049984222 }, "sources": { + "5970d6c9-26be-4cc6-84c9-16593dd3edfe": { + "name": "France", + "parameters": { + "path": "https://geodata.ucdavis.edu/gadm/gadm4.1/json/gadm41_FRA_1.json" + }, + "type": "GeoJSONSource" + }, "b4287bea-e217-443c-b527-58f7559c824c": { - "name": "Custom GeoJSON Layer Source", + "name": "World", "parameters": { "path": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson" }, diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index d0668a6c2..3d62d7b5b 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -17,7 +17,8 @@ import { ITranslator } from '@jupyterlab/translation'; import { CommandRegistry } from '@lumino/commands'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { CommandIDs, icons } from './constants'; -import { CreationFormDialog } from './dialogs/formdialog'; +// @ts-ignore +import { LayerCreationFormDialog } from './dialogs/layerCreationFormDialog'; import { LayerBrowserWidget } from './dialogs/layerBrowserDialog'; import { SymbologyWidget } from './dialogs/symbology/symbologyDialog'; import keybindings from './keybindings.json'; @@ -27,7 +28,7 @@ import { getGdal } from './gdal'; import { getGeoJSONDataFromLayerSource, downloadFile } from './tools'; import { IJGISLayer, IJGISSource } from '@jupytergis/schema'; import { UUID } from '@lumino/coreutils'; -import { FormDialog } from './formbuilder/formdialog'; +import { ProcessingFormDialog } from './dialogs/ProcessingFormDialog'; interface ICreateEntry { tracker: JupyterGISTracker; @@ -350,7 +351,7 @@ export function addCommands( // Open form and get user input const formValues = await new Promise(resolve => { - const dialog = new FormDialog({ + const dialog = new ProcessingFormDialog({ title: 'Buffer', schema: schema, model: model, @@ -359,7 +360,9 @@ export function addCommands( bufferDistance: 10, projection: 'EPSG:4326' }, + formContext: 'create', cancelButton: false, + processingType: 'buffer', syncData: (props: IDict) => { resolve(props); dialog.dispose(); @@ -454,6 +457,166 @@ export function addCommands( } }); + commands.addCommand(CommandIDs.dissolve, { + label: trans.__('Dissolve'), + isEnabled: () => { + const selectedLayer = getSingleSelectedLayer(tracker); + if (!selectedLayer) { + return false; + } + return ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type); + }, + execute: async () => { + const selected = getSingleSelectedLayer(tracker); + if (!selected) { + console.error('No valid selected layer.'); + return; + } + + const sources = tracker.currentWidget?.model.sharedModel.sources ?? {}; + const model = tracker.currentWidget?.model; + const localState = model?.sharedModel.awareness.getLocalState(); + + if ( + !model || + !localState || + !localState['selected']?.value || + !selected.parameters + ) { + return; + } + + const sourceId = selected.parameters.source; + const source = sources[sourceId]; + + if (!source || !source.parameters) { + console.error(`Source with ID ${sourceId} not found or missing path.`); + return; + } + + // Load GeoJSON data + const geojsonString = await getGeoJSONDataFromLayerSource(source, model); + if (!geojsonString) { + return; + } + + const geojson = JSON.parse(geojsonString); + if (!geojson.features || geojson.features.length === 0) { + console.error('Invalid GeoJSON: No features found.'); + return; + } + + // Extract field names from the first feature's properties + const properties = geojson.features[0].properties; + const fieldNames = Object.keys(properties); + + if (fieldNames.length === 0) { + console.error('No attribute fields found in GeoJSON.'); + return; + } + + // Retrieve dissolve schema and update fields dynamically + const schema = { + ...(formSchemaRegistry.getSchemas().get('Dissolve') as IDict), + properties: { + ...formSchemaRegistry.getSchemas().get('Dissolve')?.properties, + dissolveField: { + type: 'string', + enum: fieldNames, // Populate dropdown with field names + description: 'Select the field for dissolving features.' + } + } + }; + + const selectedLayer = localState['selected'].value; + const selectedLayerId = Object.keys(selectedLayer)[0]; + + // Open form and get user input + const formValues = await new Promise(resolve => { + const dialog = new ProcessingFormDialog({ + title: 'Dissolve', + schema: schema, + model: model, + sourceData: { + inputLayer: selectedLayerId, + dissolveField: fieldNames[0] // Default to the first field + }, + formContext: 'create', + cancelButton: false, + processingType: 'dissolve', + syncData: (props: IDict) => { + resolve(props); + dialog.dispose(); + } + }); + + dialog.launch(); + }); + + if (!formValues) { + return; + } + + const dissolveField = formValues.dissolveField; + const fileBlob = new Blob([geojsonString], { + type: 'application/geo+json' + }); + const geoFile = new File([fileBlob], 'data.geojson', { + type: 'application/geo+json' + }); + + const Gdal = await getGdal(); + const result = await Gdal.open(geoFile); + + if (result.datasets.length > 0) { + const dataset = result.datasets[0] as any; + const layerName = dataset.info.layers[0].name; + + const sqlQuery = ` + SELECT ST_Union(geometry) AS geometry, ${dissolveField} + FROM ${layerName} + GROUP BY ${dissolveField} + `; + + const options = [ + '-f', + 'GeoJSON', + '-nlt', + 'PROMOTE_TO_MULTI', + '-dialect', + 'sqlite', + '-sql', + sqlQuery, + 'output.geojson' + ]; + + const outputFilePath = await Gdal.ogr2ogr(dataset, options); + const dissolvedBytes = await Gdal.getFileBytes(outputFilePath); + const dissolvedGeoJSONString = new TextDecoder().decode(dissolvedBytes); + Gdal.close(dataset); + + const dissolvedGeoJSON = JSON.parse(dissolvedGeoJSONString); + + const newSourceId = UUID.uuid4(); + const sourceModel: IJGISSource = { + type: 'GeoJSONSource', + name: selected.name + ' Dissolved', + parameters: { data: dissolvedGeoJSON } + }; + + const layerModel: IJGISLayer = { + type: 'VectorLayer', + parameters: { source: newSourceId }, + visible: true, + name: selected.name + ' Dissolved' + }; + + model.sharedModel.addSource(newSourceId, sourceModel); + model.addLayer(UUID.uuid4(), layerModel); + } + } + }); + commands.addCommand(CommandIDs.newGeoJSONEntry, { label: trans.__('New GeoJSON layer'), isEnabled: () => { @@ -1180,12 +1343,14 @@ export function addCommands( }; const formValues = await new Promise(resolve => { - const dialog = new FormDialog({ + const dialog = new ProcessingFormDialog({ title: 'Download GeoJSON', schema: exportSchema, model, sourceData: { exportFormat: 'GeoJSON' }, + formContext: 'create', cancelButton: false, + processingType: 'export', syncData: (props: IDict) => { resolve(props); dialog.dispose(); @@ -1278,7 +1443,7 @@ namespace Private { return; } - const dialog = new CreationFormDialog({ + const dialog = new LayerCreationFormDialog({ model: current.model, title, createLayer, diff --git a/packages/base/src/constants.ts b/packages/base/src/constants.ts index 4ffce83bd..eb91c975d 100644 --- a/packages/base/src/constants.ts +++ b/packages/base/src/constants.ts @@ -26,6 +26,7 @@ export namespace CommandIDs { // Processing commands export const buffer = 'jupytergis:buffer'; + export const dissolve = 'jupytergis:dissolve'; // Sources only commands export const newRasterSource = 'jupytergis:newRasterSource'; diff --git a/packages/base/src/dialogs/ProcessingFormDialog.tsx b/packages/base/src/dialogs/ProcessingFormDialog.tsx new file mode 100644 index 000000000..cc405a247 --- /dev/null +++ b/packages/base/src/dialogs/ProcessingFormDialog.tsx @@ -0,0 +1,87 @@ +import { IDict, IJupyterGISModel } from '@jupytergis/schema'; +import { Dialog } from '@jupyterlab/apputils'; +import * as React from 'react'; +import { BaseForm, IBaseFormProps } from '../formbuilder/objectform/baseform'; +import { DissolveForm } from '../formbuilder/objectform/dissolveProcessForm'; +import { BufferForm } from '../formbuilder/objectform/bufferProcessForm'; + +export interface IProcessingFormDialogOptions extends IBaseFormProps { + formContext: 'update' | 'create'; + schema: IDict; + sourceData: IDict; + title: string; + cancelButton: (() => void) | boolean; + syncData: (props: IDict) => void; + syncSelectedPropField?: ( + id: string | null, + value: any, + parentType: 'dialog' | 'panel' + ) => void; + model: IJupyterGISModel; + processingType: 'buffer' | 'dissolve' | 'export'; +} + +export class ProcessingFormDialog extends Dialog { + constructor(options: IProcessingFormDialogOptions) { + let cancelCallback: (() => void) | undefined = undefined; + if (options.cancelButton) { + cancelCallback = () => { + if (options.cancelButton !== true && options.cancelButton !== false) { + options.cancelButton(); + } + this.resolve(0); + }; + } + + const layers = options.model.sharedModel.layers ?? {}; + + const layerOptions = Object.keys(layers).map(layerId => ({ + value: layerId, + label: layers[layerId].name + })); + + if (options.schema && options.schema.properties?.inputLayer) { + options.schema.properties.inputLayer.enum = layerOptions.map( + option => option.value + ); + options.schema.properties.inputLayer.enumNames = layerOptions.map( + option => option.label + ); + } + + const filePath = options.model.filePath; + const jgisModel = options.model; + + let FormComponent; + switch (options.processingType) { + case 'dissolve': + FormComponent = DissolveForm; + break; + case 'buffer': + FormComponent = BufferForm; + break; + case 'export': + FormComponent = BaseForm; + break; + default: + FormComponent = BaseForm; + } + + const body = ( +
+ +
+ ); + + super({ title: options.title, body, buttons: [Dialog.cancelButton()] }); + this.addClass('jGIS-processing-FormDialog'); + } +} diff --git a/packages/base/src/dialogs/layerBrowserDialog.tsx b/packages/base/src/dialogs/layerBrowserDialog.tsx index 882780bff..a932d9258 100644 --- a/packages/base/src/dialogs/layerBrowserDialog.tsx +++ b/packages/base/src/dialogs/layerBrowserDialog.tsx @@ -15,7 +15,7 @@ import { Signal } from '@lumino/signaling'; import React, { ChangeEvent, MouseEvent, useEffect, useState } from 'react'; import CUSTOM_RASTER_IMAGE from '../../rasterlayer_gallery/custom_raster.png'; -import { CreationFormWrapper } from './formdialog'; +import { CreationFormWrapper } from './layerCreationFormDialog'; interface ILayerBrowserDialogProps { model: IJupyterGISModel; diff --git a/packages/base/src/dialogs/formdialog.tsx b/packages/base/src/dialogs/layerCreationFormDialog.tsx similarity index 98% rename from packages/base/src/dialogs/formdialog.tsx rename to packages/base/src/dialogs/layerCreationFormDialog.tsx index 3815472eb..a86655ce6 100644 --- a/packages/base/src/dialogs/formdialog.tsx +++ b/packages/base/src/dialogs/layerCreationFormDialog.tsx @@ -67,7 +67,7 @@ export const CreationFormWrapper = (props: ICreationFormWrapperProps) => { /** * Form for creating a source, a layer or both at the same time */ -export class CreationFormDialog extends Dialog { +export class LayerCreationFormDialog extends Dialog { constructor(options: ICreationFormDialogOptions) { const cancelCallback = () => { this.resolve(0); diff --git a/packages/base/src/formbuilder/editform.tsx b/packages/base/src/formbuilder/editform.tsx index c4ac66b81..d91ee4b08 100644 --- a/packages/base/src/formbuilder/editform.tsx +++ b/packages/base/src/formbuilder/editform.tsx @@ -10,7 +10,7 @@ import * as React from 'react'; import { deepCopy } from '../tools'; import { getLayerTypeForm, getSourceTypeForm } from './formselectors'; import { LayerPropertiesForm } from './objectform/layerform'; -import { BaseForm } from './objectform/baseform'; +import { SourcePropertiesForm } from './objectform/sourceform'; export interface IEditFormProps { /** @@ -65,7 +65,7 @@ export class EditForm extends React.Component { } let sourceSchema: IDict | undefined = undefined; - let SourceForm: typeof BaseForm | undefined = undefined; + let SourceForm: typeof SourcePropertiesForm | undefined = undefined; let sourceData: IDict | undefined = undefined; let source: IJGISSource | undefined = undefined; if (this.props.source) { diff --git a/packages/base/src/formbuilder/formbuilder.tsx b/packages/base/src/formbuilder/formbuilder.tsx deleted file mode 100644 index 476679d1e..000000000 --- a/packages/base/src/formbuilder/formbuilder.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { ISubmitEvent } from '@rjsf/core'; -import * as React from 'react'; -import { FormComponent } from '@jupyterlab/ui-components'; -import validatorAjv8 from '@rjsf/validator-ajv8'; -import { IDict } from '../types'; - -interface IStates { - internalData?: IDict; - schema?: IDict; -} -interface IProps { - parentType: 'dialog' | 'panel'; - sourceData: IDict | undefined; - filePath?: string; - syncData: (properties: IDict) => void; - syncSelectedField?: ( - id: string | null, - value: any, - parentType: 'panel' | 'dialog' - ) => void; - schema?: IDict; - cancel?: () => void; -} - -const WrappedFormComponent = (props: any): JSX.Element => { - const { fields, ...rest } = props; - return ( - - ); -}; - -export class ObjectPropertiesForm extends React.Component { - constructor(props: IProps) { - super(props); - this.state = { - internalData: { ...this.props.sourceData }, - schema: props.schema - }; - } - - setStateByKey = (key: string, value: any): void => { - const floatValue = parseFloat(value); - if (Number.isNaN(floatValue)) { - return; - } - this.setState( - old => ({ - ...old, - internalData: { ...old.internalData, [key]: floatValue } - }), - () => this.props.syncData({ [key]: floatValue }) - ); - }; - - componentDidUpdate(prevProps: IProps): void { - if (prevProps.sourceData !== this.props.sourceData) { - this.setState(old => ({ ...old, internalData: this.props.sourceData })); - } - - if (prevProps.schema !== this.props.schema) { - this.setState(old => ({ ...old, schema: this.props.schema })); - } - } - - buildForm(): JSX.Element[] { - if (!this.props.sourceData || !this.state.internalData) { - return []; - } - const inputs: JSX.Element[] = []; - - for (const [key, value] of Object.entries(this.props.sourceData)) { - let input: JSX.Element; - if (typeof value === 'string' || typeof value === 'number') { - input = ( -
- - this.setStateByKey(key, e.target.value)} - /> -
- ); - inputs.push(input); - } - } - return inputs; - } - - removeArrayButton(schema: IDict, uiSchema: IDict): void { - if (!schema || typeof schema !== 'object') { - return; - } - - if (!schema.properties || typeof schema.properties !== 'object') { - return; - } - - Object.entries(schema.properties as IDict).forEach(([k, v]) => { - if (v && typeof v === 'object') { - if (v['type'] === 'array') { - uiSchema[k] = { - 'ui:options': { - orderable: false, - removable: false, - addable: false - } - }; - } else if (v['type'] === 'object') { - uiSchema[k] = {}; - this.removeArrayButton(v, uiSchema[k]); - } - } - }); - - uiSchema['Color'] = { - 'ui:widget': 'color' - }; - } - - generateUiSchema(schema: IDict): IDict { - const uiSchema = { - additionalProperties: { - 'ui:label': false, - classNames: 'jGIS-hidden-field' - } - }; - this.removeArrayButton(schema, uiSchema); - return uiSchema; - } - - onFormSubmit = (e: ISubmitEvent): void => { - const internalData = { ...this.state.internalData }; - Object.entries(e.formData).forEach(([k, v]) => (internalData[k] = v)); - this.setState( - old => ({ - ...old, - internalData - }), - () => { - this.props.syncData(e.formData); - this.props.cancel && this.props.cancel(); - } - ); - }; - - render(): React.ReactNode { - if (this.props.schema) { - const schema = { ...this.props.schema, additionalProperties: true }; - - const submitRef = React.createRef(); - - return ( -
-
{ - if (e.key === 'Enter') { - e.preventDefault(); - submitRef.current?.click(); - } - }} - > - { - this.props.syncSelectedField - ? this.props.syncSelectedField( - id, - value, - this.props.parentType - ) - : null; - }} - // @ts-ignore - onBlur={(id, value) => { - this.props.syncSelectedField - ? this.props.syncSelectedField( - null, - value, - this.props.parentType - ) - : null; - }} - children={ -
- -
- {this.props.cancel ? ( - - ) : null} - - -
-
- ); - } else { - return
{this.buildForm()}
; - } - } -} diff --git a/packages/base/src/formbuilder/formdialog.tsx b/packages/base/src/formbuilder/formdialog.tsx deleted file mode 100644 index f62cd676f..000000000 --- a/packages/base/src/formbuilder/formdialog.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - IDict, - IJupyterGISClientState, - IJupyterGISModel -} from '@jupytergis/schema'; -import { Dialog } from '@jupyterlab/apputils'; -import * as React from 'react'; -import { ObjectPropertiesForm } from './formbuilder'; -import { focusInputField, removeStyleFromProperty } from './utils'; - -export interface IFormDialogOptions { - schema: IDict; - sourceData: IDict; - title: string; - cancelButton: (() => void) | boolean; - syncData: (props: IDict) => void; - syncSelectedPropField?: ( - id: string | null, - value: any, - parentType: 'dialog' | 'panel' - ) => void; - model: IJupyterGISModel; -} - -export class FormDialog extends Dialog { - constructor(options: IFormDialogOptions) { - let cancelCallback: (() => void) | undefined = undefined; - if (options.cancelButton) { - cancelCallback = () => { - if (options.cancelButton !== true && options.cancelButton !== false) { - options.cancelButton(); - } - this.resolve(0); - }; - } - const layers = options.model.sharedModel.layers ?? {}; - - const layerOptions = Object.keys(layers).map(layerId => ({ - value: layerId, - label: layers[layerId].name - })); - - if (options.schema && options.schema.properties?.inputLayer) { - options.schema.properties.inputLayer.enum = layerOptions.map( - option => option.value - ); - options.schema.properties.inputLayer.enumNames = layerOptions.map( - option => option.label - ); - } - - const filePath = options.model.filePath; - const jgisModel = options.model; - const body = ( -
- -
- ); - - let lastSelectedPropFieldId: string | undefined = undefined; - - const onClientSharedStateChanged = ( - sender: IJupyterGISModel, - clients: Map - ): void => { - const remoteUser = jgisModel?.localState?.remoteUser; - if (remoteUser) { - const newState = clients.get(remoteUser); - - const id = newState?.selectedPropField?.id; - const value = newState?.selectedPropField?.value; - const parentType = newState?.selectedPropField?.parentType; - - if (parentType === 'dialog') { - lastSelectedPropFieldId = focusInputField( - `${filePath}::dialog`, - id, - value, - newState?.user?.color, - lastSelectedPropFieldId - ); - } - } else { - if (lastSelectedPropFieldId) { - removeStyleFromProperty( - `${filePath}::dialog`, - lastSelectedPropFieldId, - ['border-color', 'box-shadow'] - ); - lastSelectedPropFieldId = undefined; - } - } - }; - - jgisModel?.clientStateChanged.connect(onClientSharedStateChanged); - super({ title: options.title, body, buttons: [Dialog.cancelButton()] }); - this.addClass('jGIS-property-FormDialog'); - } -} diff --git a/packages/base/src/formbuilder/formselectors.ts b/packages/base/src/formbuilder/formselectors.ts index f943511e6..f6d39ddee 100644 --- a/packages/base/src/formbuilder/formselectors.ts +++ b/packages/base/src/formbuilder/formselectors.ts @@ -1,5 +1,4 @@ import { LayerType, SourceType } from '@jupytergis/schema'; -import { BaseForm } from './objectform/baseform'; import { GeoJSONSourcePropertiesForm } from './objectform/geojsonsource'; import { GeoTiffSourcePropertiesForm } from './objectform/geotiffsource'; import { HeatmapLayerPropertiesForm } from './objectform/heatmapLayerForm'; @@ -9,6 +8,7 @@ import { PathBasedSourcePropertiesForm } from './objectform/pathbasedsource'; import { TileSourcePropertiesForm } from './objectform/tilesourceform'; import { VectorLayerPropertiesForm } from './objectform/vectorlayerform'; import { WebGlLayerPropertiesForm } from './objectform/webGlLayerForm'; +import { SourcePropertiesForm } from './objectform/sourceform'; export function getLayerTypeForm( layerType: LayerType @@ -34,8 +34,10 @@ export function getLayerTypeForm( return LayerForm; } -export function getSourceTypeForm(sourceType: SourceType): typeof BaseForm { - let SourceForm = BaseForm; +export function getSourceTypeForm( + sourceType: SourceType +): typeof SourcePropertiesForm { + let SourceForm = SourcePropertiesForm; switch (sourceType) { case 'GeoJSONSource': SourceForm = GeoJSONSourcePropertiesForm; diff --git a/packages/base/src/formbuilder/objectform/baseform.tsx b/packages/base/src/formbuilder/objectform/baseform.tsx index f993ff35a..d943cb500 100644 --- a/packages/base/src/formbuilder/objectform/baseform.tsx +++ b/packages/base/src/formbuilder/objectform/baseform.tsx @@ -1,5 +1,5 @@ import { Slider } from '@jupyter/react-components'; -import { IJupyterGISModel, SourceType } from '@jupytergis/schema'; +import { IJupyterGISModel } from '@jupytergis/schema'; import { Dialog } from '@jupyterlab/apputils'; import { FormComponent } from '@jupyterlab/ui-components'; import { Signal } from '@lumino/signaling'; @@ -66,17 +66,6 @@ export interface IBaseFormProps { * extra errors or not. */ formErrorSignal?: Signal, boolean>; - - /** - * Configuration options for the dialog, including settings for layer data, source data, - * and other form-related parameters. - */ - dialogOptions?: any; - - /** - * Source type property - */ - sourceType: SourceType; } const WrappedFormComponent = (props: any): JSX.Element => { diff --git a/packages/base/src/formbuilder/objectform/bufferProcessForm.tsx b/packages/base/src/formbuilder/objectform/bufferProcessForm.tsx new file mode 100644 index 000000000..acf699474 --- /dev/null +++ b/packages/base/src/formbuilder/objectform/bufferProcessForm.tsx @@ -0,0 +1,89 @@ +import { BaseForm, IBaseFormProps, IBaseFormStates } from './baseform'; // Ensure BaseForm imports states +import { IDict, IJupyterGISModel } from '@jupytergis/schema'; +import { IChangeEvent } from '@rjsf/core'; +// import { loadFile } from '../../tools'; +import proj4 from "proj4"; + +interface IBufferFormOptions extends IBaseFormProps { + schema: IDict; + sourceData: IDict; + title: string; + cancelButton: (() => void) | boolean; + syncData: (props: IDict) => void; + model: IJupyterGISModel; +} + +export class BufferForm extends BaseForm { + private model: IJupyterGISModel; + private unit: string = ''; + + constructor(options: IBufferFormOptions) { + super(options); + this.model = options.model; + + // Ensure initial state matches IBaseFormStates + this.state = { + schema: options.schema ?? {} // Ensure schema is never undefined + }; + + this.onFormChange = this.handleFormChange.bind(this); + + this.computeDistanceUnits(options.sourceData.inputLayer); + } + + private async computeDistanceUnits(layerId: string) { + const layer = this.model.getLayer(layerId); + if (!layer?.parameters?.source) { + return; + } + const source = this.model.getSource(layer.parameters.source); + if (!source) return; + + const projection = source.parameters?.projection + console.log(projection); + + // TODO: how to get layer info from OpenLayers? + // const srs = layer.from_ol().srs; + const srs = "EPSG:4326"; + + try { + // console.log(proj4, srs); + this.unit = (proj4(srs) as any).oProj.units; + debugger; + this.updateSchema(); + } catch (error) { + console.error('Error calculating units:', error); + } + } + + public handleFormChange(e: IChangeEvent) { + super.onFormChange(e); + + if (e.formData.inputLayer) { + this.computeDistanceUnits(e.formData.inputLayer); + } + } + + private updateSchema() { + this.setState( + (prevState: IBaseFormStates) => ({ + schema: { + ...prevState.schema, + properties: { + ...prevState.schema?.properties, + bufferDistance: { + ...prevState.schema?.properties?.bufferDistance, + description: prevState.schema?.properties?.bufferDistance.description.replace( + "projection units", + this.unit, + ) + } + } + } + }), + () => { + this.forceUpdate(); + } + ); + } +} diff --git a/packages/base/src/formbuilder/objectform/dissolveProcessForm.tsx b/packages/base/src/formbuilder/objectform/dissolveProcessForm.tsx new file mode 100644 index 000000000..cea1b489e --- /dev/null +++ b/packages/base/src/formbuilder/objectform/dissolveProcessForm.tsx @@ -0,0 +1,94 @@ +import { BaseForm, IBaseFormProps, IBaseFormStates } from './baseform'; // Ensure BaseForm imports states +import { IDict, IJupyterGISModel, IGeoJSONSource } from '@jupytergis/schema'; +import { IChangeEvent } from '@rjsf/core'; +import { loadFile } from '../../tools'; + +interface IDissolveFormOptions extends IBaseFormProps { + schema: IDict; + sourceData: IDict; + title: string; + cancelButton: (() => void) | boolean; + syncData: (props: IDict) => void; + model: IJupyterGISModel; +} + +export class DissolveForm extends BaseForm { + private model: IJupyterGISModel; + private features: string[] = []; + + constructor(options: IDissolveFormOptions) { + super(options); + this.model = options.model; + + // Ensure initial state matches IBaseFormStates + this.state = { + schema: options.schema ?? {} // Ensure schema is never undefined + }; + + this.onFormChange = this.handleFormChange.bind(this); + + this.fetchFieldNames(options.sourceData.inputLayer); + } + + private async fetchFieldNames(layerId: string) { + const layer = this.model.getLayer(layerId); + if (!layer?.parameters?.source) { + return; + } + + const source = this.model.getSource(layer.parameters.source); + if (!source || source.type !== 'GeoJSONSource') { + return; + } + + const sourceData = source.parameters as IGeoJSONSource; + if (!sourceData?.path) { + return; + } + + try { + const jsonData = await loadFile({ + filepath: sourceData.path, + type: 'GeoJSONSource', + model: this.model + }); + + if (!jsonData?.features?.length) { + return; + } + + this.features = Object.keys(jsonData.features[0].properties); + this.updateSchema(); + } catch (error) { + console.error('Error loading GeoJSON:', error); + } + } + + public handleFormChange(e: IChangeEvent) { + super.onFormChange(e); + + if (e.formData.inputLayer) { + this.fetchFieldNames(e.formData.inputLayer); + } + } + + private updateSchema() { + this.setState( + (prevState: IBaseFormStates) => ({ + schema: { + ...prevState.schema, + properties: { + ...prevState.schema?.properties, + dissolveField: { + ...prevState.schema?.properties?.dissolveField, + enum: [...this.features] + } + } + } + }), + () => { + this.forceUpdate(); + } + ); + } +} diff --git a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx index f43af6be6..b2d119ad2 100644 --- a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx +++ b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { FileDialog } from '@jupyterlab/filebrowser'; import { Dialog } from '@jupyterlab/apputils'; -import { CreationFormDialog } from '../../dialogs/formdialog'; +import { LayerCreationFormDialog } from '../../dialogs/layerCreationFormDialog'; import { PathExt } from '@jupyterlab/coreutils'; export const FileSelectorWidget = (props: any) => { @@ -81,14 +81,14 @@ export const FileSelectorWidget = (props: any) => { }; } - const formDialog = new CreationFormDialog({ + const formDialog = new LayerCreationFormDialog({ ...formOptions.dialogOptions }); await formDialog.launch(); } } else { if (dialogElement) { - const formDialog = new CreationFormDialog({ + const formDialog = new LayerCreationFormDialog({ ...formOptions.dialogOptions }); await formDialog.launch(); diff --git a/packages/base/src/formbuilder/objectform/geojsonsource.ts b/packages/base/src/formbuilder/objectform/geojsonsource.ts index 81b3c66a4..7572c4e04 100644 --- a/packages/base/src/formbuilder/objectform/geojsonsource.ts +++ b/packages/base/src/formbuilder/objectform/geojsonsource.ts @@ -2,17 +2,18 @@ import { IDict } from '@jupytergis/schema'; import { Ajv, ValidateFunction } from 'ajv'; import * as geojson from '@jupytergis/schema/src/schema/geojson.json'; -import { IBaseFormProps } from './baseform'; import { PathBasedSourcePropertiesForm } from './pathbasedsource'; import { loadFile } from '../../tools'; +import { ISourceFormProps } from './sourceform'; /** * The form to modify a GeoJSON source. */ export class GeoJSONSourcePropertiesForm extends PathBasedSourcePropertiesForm { private _validate: ValidateFunction; + props: ISourceFormProps; - constructor(props: IBaseFormProps) { + constructor(props: ISourceFormProps) { super(props); const ajv = new Ajv(); this._validate = ajv.compile(geojson); diff --git a/packages/base/src/formbuilder/objectform/geotiffsource.ts b/packages/base/src/formbuilder/objectform/geotiffsource.ts index 9662e782f..ef930607e 100644 --- a/packages/base/src/formbuilder/objectform/geotiffsource.ts +++ b/packages/base/src/formbuilder/objectform/geotiffsource.ts @@ -2,16 +2,17 @@ import { IDict } from '@jupytergis/schema'; import { showErrorMessage } from '@jupyterlab/apputils'; import { IChangeEvent, ISubmitEvent } from '@rjsf/core'; -import { BaseForm, IBaseFormProps } from './baseform'; import { getMimeType } from '../../tools'; +import { ISourceFormProps, SourcePropertiesForm } from './sourceform'; /** * The form to modify a GeoTiff source. */ -export class GeoTiffSourcePropertiesForm extends BaseForm { +export class GeoTiffSourcePropertiesForm extends SourcePropertiesForm { private _isSubmitted: boolean; + props: ISourceFormProps; - constructor(props: IBaseFormProps) { + constructor(props: ISourceFormProps) { super(props); this._isSubmitted = false; diff --git a/packages/base/src/formbuilder/objectform/layerform.ts b/packages/base/src/formbuilder/objectform/layerform.ts index 5b4acbf00..f6ca71f27 100644 --- a/packages/base/src/formbuilder/objectform/layerform.ts +++ b/packages/base/src/formbuilder/objectform/layerform.ts @@ -13,6 +13,12 @@ export interface ILayerProps extends IBaseFormProps { * The signal emitted when the attached source form has changed, if it exists */ sourceFormChangedSignal?: Signal>; + + /** + * Configuration options for the dialog, including settings for layer data, source data, + * and other form-related parameters. + */ + dialogOptions?: any; } export class LayerPropertiesForm extends BaseForm { diff --git a/packages/base/src/formbuilder/objectform/pathbasedsource.ts b/packages/base/src/formbuilder/objectform/pathbasedsource.ts index 6d22013b8..0d196228c 100644 --- a/packages/base/src/formbuilder/objectform/pathbasedsource.ts +++ b/packages/base/src/formbuilder/objectform/pathbasedsource.ts @@ -2,15 +2,16 @@ import { IDict } from '@jupytergis/schema'; import { showErrorMessage } from '@jupyterlab/apputils'; import { IChangeEvent, ISubmitEvent } from '@rjsf/core'; -import { BaseForm, IBaseFormProps } from './baseform'; import { loadFile } from '../../tools'; import { FileSelectorWidget } from './fileselectorwidget'; +import { ISourceFormProps, SourcePropertiesForm } from './sourceform'; /** * The form to modify a PathBasedSource source. */ -export class PathBasedSourcePropertiesForm extends BaseForm { - constructor(props: IBaseFormProps) { +export class PathBasedSourcePropertiesForm extends SourcePropertiesForm { + props: ISourceFormProps; + constructor(props: ISourceFormProps) { super(props); if (this.props.sourceType !== 'GeoJSONSource') { diff --git a/packages/base/src/formbuilder/objectform/sourceform.ts b/packages/base/src/formbuilder/objectform/sourceform.ts new file mode 100644 index 000000000..c7915ef9a --- /dev/null +++ b/packages/base/src/formbuilder/objectform/sourceform.ts @@ -0,0 +1,38 @@ +import { IDict, SourceType } from '@jupytergis/schema'; +import { BaseForm, IBaseFormProps } from './baseform'; +import { Signal } from '@lumino/signaling'; +import { IChangeEvent } from '@rjsf/core'; + +export interface ISourceFormProps extends IBaseFormProps { + /** + * The source type for this form. + */ + sourceType: SourceType; + + /** + * The signal emitted when the source form has changed. + */ + sourceFormChangedSignal?: Signal>; + + /** + * Configuration options for the dialog, including settings for source data and other parameters. + */ + dialogOptions?: any; +} + +export class SourcePropertiesForm extends BaseForm { + props: ISourceFormProps; + protected sourceFormChangedSignal: Signal> | undefined; + + constructor(props: ISourceFormProps) { + super(props); + this.sourceFormChangedSignal = props.sourceFormChangedSignal; + } + + protected onFormChange(e: IChangeEvent): void { + super.onFormChange(e); + if (this.props.dialogOptions) { + this.props.dialogOptions.sourceData = { ...e.formData }; + } + } +} diff --git a/packages/base/src/formbuilder/objectform/tilesourceform.ts b/packages/base/src/formbuilder/objectform/tilesourceform.ts index 999380437..6fb1663b0 100644 --- a/packages/base/src/formbuilder/objectform/tilesourceform.ts +++ b/packages/base/src/formbuilder/objectform/tilesourceform.ts @@ -1,7 +1,7 @@ import { IDict } from '@jupytergis/schema'; -import { BaseForm } from './baseform'; +import { SourcePropertiesForm } from './sourceform'; -export class TileSourcePropertiesForm extends BaseForm { +export class TileSourcePropertiesForm extends SourcePropertiesForm { private _urlParameters: string[] = []; protected processSchema( diff --git a/packages/base/src/formbuilder/utils.ts b/packages/base/src/formbuilder/utils.ts deleted file mode 100644 index 05ab4e975..000000000 --- a/packages/base/src/formbuilder/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function focusInputField( - filePath?: string, - fieldId?: string | null, - value?: any, - color?: string, - lastSelectedPropFieldId?: string -): string | undefined { - const propsToRemove = ['border-color', 'box-shadow']; - let newSelected: string | undefined; - if (!fieldId) { - if (lastSelectedPropFieldId) { - removeStyleFromProperty(filePath, lastSelectedPropFieldId, propsToRemove); - if (value) { - const el = getElementFromProperty(filePath, lastSelectedPropFieldId); - if (el?.tagName?.toLowerCase() === 'input') { - (el as HTMLInputElement).value = value; - } - } - newSelected = undefined; - } - } else { - if (fieldId !== lastSelectedPropFieldId) { - removeStyleFromProperty(filePath, lastSelectedPropFieldId, propsToRemove); - - const el = getElementFromProperty(filePath, fieldId); - if (el) { - el.style.borderColor = color ?? 'red'; - el.style.boxShadow = `inset 0 0 4px ${color ?? 'red'}`; - } - newSelected = fieldId; - } - } - return newSelected; -} - -export function getElementFromProperty( - filePath?: string | null, - prop?: string | null -): HTMLElement | undefined | null { - if (!filePath || !prop) { - return; - } - const parent = document.querySelector(`[data-path="${filePath}"]`); - - if (parent) { - const el = parent.querySelector(`[id$=${prop}]`); - return el as HTMLElement; - } -} - -export function removeStyleFromProperty( - filePath: string | null | undefined, - prop: string | null | undefined, - properties: string[] -): void { - if (!filePath || !prop || properties.length === 0) { - return; - } - const el = getElementFromProperty(filePath, prop); - if (el) { - properties.forEach(prop => el.style.removeProperty(prop)); - } -} diff --git a/packages/base/src/index.ts b/packages/base/src/index.ts index ce0e8029b..714b6d5b1 100644 --- a/packages/base/src/index.ts +++ b/packages/base/src/index.ts @@ -1,7 +1,7 @@ export * from './classificationModes'; export * from './commands'; export * from './constants'; -export * from './dialogs/formdialog'; +export * from './dialogs/layerCreationFormDialog'; export * from './formbuilder/objectform/baseform'; export * from './icons'; export * from './mainview'; diff --git a/packages/schema/src/schema/buffer.json b/packages/schema/src/schema/buffer.json index dd12abcc5..88f9f7884 100644 --- a/packages/schema/src/schema/buffer.json +++ b/packages/schema/src/schema/buffer.json @@ -13,11 +13,6 @@ "type": "number", "default": 10, "description": "The distance used for buffering the geometry (in projection units)." - }, - "projection": { - "type": "string", - "description": "The spatial reference system of the buffered output.", - "default": "EPSG:4326" } } } diff --git a/packages/schema/src/schema/dissolve.json b/packages/schema/src/schema/dissolve.json new file mode 100644 index 000000000..5470af902 --- /dev/null +++ b/packages/schema/src/schema/dissolve.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "description": "Dissolve", + "title": "IDissolve", + "required": ["inputLayer", "dissolveField"], + "additionalProperties": false, + "properties": { + "inputLayer": { + "type": "string", + "description": "The input layer for the dissolve operation." + }, + "dissolveField": { + "type": "string", + "description": "The field based on which geometries will be dissolved." + } + } +} diff --git a/python/jupytergis_lab/src/index.ts b/python/jupytergis_lab/src/index.ts index dd0f625c7..4178bdbd3 100644 --- a/python/jupytergis_lab/src/index.ts +++ b/python/jupytergis_lab/src/index.ts @@ -197,6 +197,10 @@ const plugin: JupyterFrontEndPlugin = { command: CommandIDs.buffer }); + processingSubmenu.addItem({ + command: CommandIDs.dissolve + }); + app.contextMenu.addItem({ type: 'submenu', selector: '.jp-gis-layerItem',