diff --git a/packages/base/src/commands/BaseCommandIDs.ts b/packages/base/src/commands/BaseCommandIDs.ts index b958bebe2..af7069dbf 100644 --- a/packages/base/src/commands/BaseCommandIDs.ts +++ b/packages/base/src/commands/BaseCommandIDs.ts @@ -18,15 +18,15 @@ export const getGeolocation = 'jupytergis:getGeolocation'; export const openLayerBrowser = 'jupytergis:openLayerBrowser'; // Layer and source -export const newRasterEntry = 'jupytergis:newRasterEntry'; -export const newVectorTileEntry = 'jupytergis:newVectorTileEntry'; -export const newShapefileEntry = 'jupytergis:newShapefileEntry'; -export const newGeoJSONEntry = 'jupytergis:newGeoJSONEntry'; -export const newHillshadeEntry = 'jupytergis:newHillshadeEntry'; -export const newImageEntry = 'jupytergis:newImageEntry'; -export const newVideoEntry = 'jupytergis:newVideoEntry'; -export const newGeoTiffEntry = 'jupytergis:newGeoTiffEntry'; -export const newGeoParquetEntry = 'jupytergis:newGeoParquetEntry'; +export const openNewRasterDialog = 'jupytergis:openNewRasterDialog'; +export const openNewVectorTileDialog = 'jupytergis:openNewVectorTileDialog'; +export const openNewShapefileDialog = 'jupytergis:openNewShapefileDialog'; +export const openNewGeoJSONDialog = 'jupytergis:openNewGeoJSONDialog'; +export const openNewHillshadeDialog = 'jupytergis:openNewHillshadeDialog'; +export const openNewImageDialog = 'jupytergis:openNewImageDialog'; +export const openNewVideoDialog = 'jupytergis:openNewVideoDialog'; +export const openNewGeoTiffDialog = 'jupytergis:openNewGeoTiffDialog'; +export const openNewGeoParquetDialog = 'jupytergis:openNewGeoParquetDialog'; // Layer and group actions export const renameSelected = 'jupytergis:renameSelected'; diff --git a/packages/base/src/commands/index.ts b/packages/base/src/commands/index.ts index 042277e4e..bac4dcb8b 100644 --- a/packages/base/src/commands/index.ts +++ b/packages/base/src/commands/index.ts @@ -31,6 +31,8 @@ import { addProcessingCommands } from '../processing/processingCommands'; import { getGeoJSONDataFromLayerSource, downloadFile } from '../tools'; import { JupyterGISTracker, SYMBOLOGY_VALID_LAYER_TYPES } from '../types'; import { JupyterGISDocumentWidget } from '../widget'; +import { addLayerCreationCommands } from './operationCommands'; +import { addProcessingCommandsFromParams } from './processingCommandsFromParams'; const POINT_SELECTION_TOOL_CLASS = 'jGIS-point-selection-tool'; @@ -71,6 +73,7 @@ export function addCommands( const trans = translator.load('jupyterlab'); const { commands } = app; + addLayerCreationCommands({ tracker, commands, trans }); /** * Wraps a command definition to automatically disable it in Specta mode */ @@ -108,6 +111,17 @@ export function addCommands( commands.addCommand(CommandIDs.symbology, { label: trans.__('Edit Symbology'), + describedBy: { + args: { + type: 'object', + properties: { + selected: { + type: 'object', + description: 'Currently selected layer(s) in the map view', + }, + }, + }, + }, isEnabled: () => { const model = tracker.currentWidget?.model; const localState = model?.sharedModel.awareness.getLocalState(); @@ -141,13 +155,29 @@ export function addCommands( commands.addCommand(CommandIDs.redo, { label: trans.__('Redo'), + describedBy: { + args: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: + 'Optional .jGIS file path. If omitted, uses active widget.', + }, + }, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable : false; }, - execute: () => { - const current = tracker.currentWidget; + execute: (args?: { filePath?: string }) => { + const filePath = args?.filePath; + + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; if (current) { return current.model.sharedModel.redo(); @@ -158,13 +188,30 @@ export function addCommands( commands.addCommand(CommandIDs.undo, { label: trans.__('Undo'), + describedBy: { + args: { + type: 'object', + required: [], + properties: { + filePath: { + type: 'string', + description: + 'Optional .jGIS file path. If omitted, uses active widget.', + }, + }, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable : false; }, - execute: () => { - const current = tracker.currentWidget; + execute: (args: { filePath?: string }) => { + const filePath = args?.filePath; + + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; if (current) { return current.model.sharedModel.undo(); @@ -175,6 +222,20 @@ export function addCommands( commands.addCommand(CommandIDs.identify, { label: trans.__('Identify'), + describedBy: { + args: { + type: 'object', + required: [], + properties: { + filePath: { + type: 'string', + description: + 'Optional .jGIS file path. If omitted, uses active widget.', + }, + }, + }, + }, + isToggled: () => { const current = tracker.currentWidget; if (!current) { @@ -185,6 +246,7 @@ export function addCommands( if (!selectedLayer) { return false; } + const canIdentify = [ 'VectorLayer', 'ShapefileLayer', @@ -215,8 +277,14 @@ export function addCommands( 'VectorTileLayer', ].includes(selectedLayer.type); }, + execute: args => { - const current = tracker.currentWidget; + const filePath = args?.filePath; + + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; + if (!current) { return; } @@ -245,6 +313,17 @@ export function addCommands( commands.addCommand(CommandIDs.temporalController, { label: trans.__('Temporal Controller'), + describedBy: { + args: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Optional path to the .jGIS file', + }, + }, + }, + }, isToggled: () => { return tracker.currentWidget?.model.isTemporalControllerActive || false; }, @@ -280,8 +359,14 @@ export function addCommands( return true; }, - execute: () => { - const current = tracker.currentWidget; + + execute: (args?: { filePath?: string }) => { + const filePath = args?.filePath; + + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; + if (!current) { return; } @@ -297,6 +382,12 @@ export function addCommands( */ commands.addCommand(CommandIDs.openLayerBrowser, { label: trans.__('Open Layer Browser'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -313,8 +404,14 @@ export function addCommands( /** * Source and layers */ - commands.addCommand(CommandIDs.newRasterEntry, { - label: trans.__('New Raster Tile Layer'), + commands.addCommand(CommandIDs.openNewRasterDialog, { + label: trans.__('Open New Raster Tile Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -334,11 +431,17 @@ export function addCommands( sourceType: 'RasterSource', layerType: 'RasterLayer', }), - ...icons.get(CommandIDs.newRasterEntry), + ...icons.get(CommandIDs.openNewRasterDialog), }); - commands.addCommand(CommandIDs.newVectorTileEntry, { - label: trans.__('New Vector Tile Layer'), + commands.addCommand(CommandIDs.openNewVectorTileDialog, { + label: trans.__('Open New Vector Tile Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -355,11 +458,17 @@ export function addCommands( sourceType: 'VectorTileSource', layerType: 'VectorTileLayer', }), - ...icons.get(CommandIDs.newVectorTileEntry), + ...icons.get(CommandIDs.openNewVectorTileDialog), }); - commands.addCommand(CommandIDs.newGeoParquetEntry, { - label: trans.__('New GeoParquet Layer'), + commands.addCommand(CommandIDs.openNewGeoParquetDialog, { + label: trans.__('Open New GeoParquet Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -376,11 +485,17 @@ export function addCommands( sourceType: 'GeoParquetSource', layerType: 'VectorLayer', }), - ...icons.get(CommandIDs.newGeoParquetEntry), + ...icons.get(CommandIDs.openNewGeoParquetDialog), }); - commands.addCommand(CommandIDs.newGeoJSONEntry, { - label: trans.__('New GeoJSON layer'), + commands.addCommand(CommandIDs.openNewGeoJSONDialog, { + label: trans.__('Open New GeoJSON Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -396,14 +511,28 @@ export function addCommands( sourceType: 'GeoJSONSource', layerType: 'VectorLayer', }), - ...icons.get(CommandIDs.newGeoJSONEntry), + ...icons.get(CommandIDs.openNewGeoJSONDialog), }); //Add processing commands addProcessingCommands(app, commands, tracker, trans, formSchemaRegistry); + addProcessingCommandsFromParams({ + app, + commands, + tracker, + trans, + formSchemaRegistry, + processingSchemas: Object.fromEntries(formSchemaRegistry.getSchemas()), + }); - commands.addCommand(CommandIDs.newHillshadeEntry, { - label: trans.__('New Hillshade layer'), + commands.addCommand(CommandIDs.openNewHillshadeDialog, { + label: trans.__('Open New Hillshade Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -419,11 +548,17 @@ export function addCommands( sourceType: 'RasterDemSource', layerType: 'HillshadeLayer', }), - ...icons.get(CommandIDs.newHillshadeEntry), + ...icons.get(CommandIDs.openNewHillshadeDialog), }); - commands.addCommand(CommandIDs.newImageEntry, { - label: trans.__('New Image layer'), + commands.addCommand(CommandIDs.openNewImageDialog, { + label: trans.__('Open New Image Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -449,11 +584,17 @@ export function addCommands( sourceType: 'ImageSource', layerType: 'ImageLayer', }), - ...icons.get(CommandIDs.newImageEntry), + ...icons.get(CommandIDs.openNewImageDialog), }); - commands.addCommand(CommandIDs.newVideoEntry, { - label: trans.__('New Video layer'), + commands.addCommand(CommandIDs.openNewVideoDialog, { + label: trans.__('Open New Video Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -482,11 +623,17 @@ export function addCommands( sourceType: 'VideoSource', layerType: 'RasterLayer', }), - ...icons.get(CommandIDs.newVideoEntry), + ...icons.get(CommandIDs.openNewVideoDialog), }); - commands.addCommand(CommandIDs.newGeoTiffEntry, { - label: trans.__('New GeoTiff layer'), + commands.addCommand(CommandIDs.openNewGeoTiffDialog, { + label: trans.__('Open New GeoTiff Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -506,11 +653,17 @@ export function addCommands( sourceType: 'GeoTiffSource', layerType: 'WebGlLayer', }), - ...icons.get(CommandIDs.newGeoTiffEntry), + ...icons.get(CommandIDs.openNewGeoTiffDialog), }); - commands.addCommand(CommandIDs.newShapefileEntry, { - label: trans.__('New Shapefile Layer'), + commands.addCommand(CommandIDs.openNewShapefileDialog, { + label: trans.__('Open New Shapefile Dialog'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => { return tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable @@ -527,7 +680,7 @@ export function addCommands( sourceType: 'ShapefileSource', layerType: 'VectorLayer', }), - ...icons.get(CommandIDs.newShapefileEntry), + ...icons.get(CommandIDs.openNewShapefileDialog), }); /** @@ -576,26 +729,104 @@ export function addCommands( commands.addCommand(CommandIDs.moveLayersToGroup, { label: args => args['label'] ? (args['label'] as string) : trans.__('Move to Root'), - execute: args => { - const model = tracker.currentWidget?.model; - const groupName = args['label'] as string; + describedBy: { + args: { + type: 'object', + properties: { + label: { type: 'string' }, + filePath: { type: 'string' }, + layerIds: { + type: 'array', + items: { type: 'string' }, + }, + groupName: { type: 'string' }, + }, + }, + }, + + execute: (args?: { + filePath?: string; + layerIds?: string[]; + groupName?: string; + label?: string; + }) => { + const { filePath, layerIds, groupName } = args ?? {}; - const selectedLayers = model?.localState?.selected?.value; + // Resolve model based on filePath or current widget + const model = filePath + ? tracker.find(w => w.model.filePath === filePath)?.model + : tracker.currentWidget?.model; + + if (!model || !model.sharedModel.editable) { + return; + } + // ---- PARAMETER MODE ---- + if (filePath && layerIds && groupName !== undefined) { + model.moveItemsToGroup(layerIds, groupName); + return; + } + + // ---- FALLBACK TO ORIGINAL INTERACTIVE BEHAVIOR ---- + const selectedLayers = model.localState?.selected?.value; if (!selectedLayers) { return; } - model.moveItemsToGroup(Object.keys(selectedLayers), groupName); + const targetGroup = args?.label as string; + model.moveItemsToGroup(Object.keys(selectedLayers), targetGroup); }, }); commands.addCommand(CommandIDs.moveLayerToNewGroup, { label: trans.__('Move Selected Layers to New Group'), - execute: async () => { - const model = tracker.currentWidget?.model; - const selectedLayers = model?.localState?.selected?.value; + describedBy: { + args: { + type: 'object', + properties: { + filePath: { type: 'string' }, + groupName: { type: 'string' }, + layerIds: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + + execute: async (args?: { + filePath?: string; + groupName?: string; + layerIds?: string[]; + }) => { + const { filePath, groupName, layerIds } = args ?? {}; + + const model = filePath + ? tracker.find(w => w.model.filePath === filePath)?.model + : tracker.currentWidget?.model; + + if (!model || !model.sharedModel.editable) { + return; + } + + // ---- PARAMETER MODE ---- + if (filePath && groupName && layerIds) { + const layerMap: { [key: string]: any } = {}; + layerIds.forEach(id => { + layerMap[id] = { type: 'layer', selectedNodeId: id }; + }); + const newGroup: IJGISLayerGroup = { + name: groupName, + layers: layerIds, + }; + + model.addNewLayerGroup(layerMap, newGroup); + return; + } + + // ---- FALLBACK TO ORIGINAL INTERACTIVE BEHAVIOR ---- + const selectedLayers = model.localState?.selected?.value; if (!selectedLayers) { return; } @@ -675,6 +906,12 @@ export function addCommands( // Console commands commands.addCommand(CommandIDs.toggleConsole, { label: trans.__('Toggle console'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isVisible: () => tracker.currentWidget instanceof JupyterGISDocumentWidget, isEnabled: () => { return tracker.currentWidget @@ -693,8 +930,15 @@ export function addCommands( commands.notifyCommandChanged(CommandIDs.toggleConsole); }, }); + commands.addCommand(CommandIDs.executeConsole, { label: trans.__('Execute console'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isVisible: () => tracker.currentWidget instanceof JupyterGISDocumentWidget, isEnabled: () => { return tracker.currentWidget @@ -703,8 +947,15 @@ export function addCommands( }, execute: () => Private.executeConsole(tracker), }); + commands.addCommand(CommandIDs.removeConsole, { label: trans.__('Remove console'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isVisible: () => tracker.currentWidget instanceof JupyterGISDocumentWidget, isEnabled: () => { return tracker.currentWidget @@ -716,6 +967,12 @@ export function addCommands( commands.addCommand(CommandIDs.invokeCompleter, { label: trans.__('Display the completion helper.'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isVisible: () => tracker.currentWidget instanceof JupyterGISDocumentWidget, execute: () => { const currentWidget = tracker.currentWidget; @@ -735,6 +992,12 @@ export function addCommands( commands.addCommand(CommandIDs.selectCompleter, { label: trans.__('Select the completion suggestion.'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isVisible: () => tracker.currentWidget instanceof JupyterGISDocumentWidget, execute: () => { const currentWidget = tracker.currentWidget; @@ -754,38 +1017,133 @@ export function addCommands( commands.addCommand(CommandIDs.zoomToLayer, { label: trans.__('Zoom to Layer'), - execute: () => { - const currentWidget = tracker.currentWidget; - if (!currentWidget || !completionProviderManager) { + describedBy: { + args: { + type: 'object', + properties: { + filePath: { type: 'string' }, + layerId: { type: 'string' }, + }, + }, + }, + + execute: (args?: { filePath?: string; layerId?: string }) => { + const { filePath, layerId } = args ?? {}; + + // Determine model from provided file path or fallback to current widget + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; + + if (!current || !current.model.sharedModel.editable) { + return; + } + + const model = current.model; + + // ----- PARAMETER MODE ----- + if (filePath && layerId) { + console.log(`Zooming to layer: ${layerId}`); + model.centerOnPosition(layerId); return; } - const model = tracker.currentWidget.model; - const selectedItems = model.localState?.selected.value; + // ----- FALLBACK TO ORIGINAL INTERACTIVE BEHAVIOR ----- + const selectedItems = model.localState?.selected?.value; if (!selectedItems) { return; } - const layerId = Object.keys(selectedItems)[0]; - model.centerOnPosition(layerId); + const selLayerId = Object.keys(selectedItems)[0]; + console.log('zooming'); + model.centerOnPosition(selLayerId); }, }); commands.addCommand(CommandIDs.downloadGeoJSON, { label: trans.__('Download as GeoJSON'), + describedBy: { + args: { + type: 'object', + properties: { + filePath: { type: 'string' }, + layerId: { type: 'string' }, + exportFileName: { type: 'string' }, + }, + }, + }, + isEnabled: () => { - const selectedLayer = getSingleSelectedLayer(tracker); - return selectedLayer - ? ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type) - : false; + const layer = getSingleSelectedLayer(tracker); + return !!layer && ['VectorLayer', 'ShapefileLayer'].includes(layer.type); }, - execute: async () => { + + execute: async (args?: { + filePath?: string; + layerId?: string; + exportFileName?: string; + }) => { + const exportLayer = async ( + model: IJupyterGISModel, + layer: any, + exportFileName: string, + ) => { + if (!['VectorLayer', 'ShapefileLayer'].includes(layer.type)) { + console.warn('Layer type not supported for GeoJSON export'); + return; + } + + const sources = model.sharedModel.sources ?? {}; + const sourceId = layer.parameters?.source; + const source = sources[sourceId]; + if (!source) { + console.warn('Source not found for selected layer'); + return; + } + + const geojsonString = await getGeoJSONDataFromLayerSource( + source, + model, + ); + if (!geojsonString) { + console.warn('Failed to generate GeoJSON data'); + return; + } + + downloadFile( + geojsonString, + `${exportFileName}.geojson`, + 'application/geo+json', + ); + }; + + const { filePath, layerId, exportFileName } = args ?? {}; + + // ----- PARAMETER MODE ----- + if (filePath && layerId && exportFileName) { + const widget = tracker.find(w => w.model.filePath === filePath); + if (!widget || !widget.model.sharedModel.editable) { + console.warn('Invalid or non-editable document'); + return; + } + + const model = widget.model; + const layer = model.getLayer(layerId); + if (!layer) { + console.warn('Layer not found'); + return; + } + + return exportLayer(model, layer, exportFileName); + } + + // ----- INTERACTIVE MODE ----- const selectedLayer = getSingleSelectedLayer(tracker); - if (!selectedLayer) { + const model = tracker.currentWidget?.model as IJupyterGISModel; + + if (!selectedLayer || !model) { return; } - const model = tracker.currentWidget?.model as IJupyterGISModel; - const sources = model.sharedModel.sources ?? {}; const exportSchema = { ...(formSchemaRegistry @@ -814,48 +1172,60 @@ export function addCommands( return; } - const exportFileName = formValues.exportFileName; - const sourceId = selectedLayer.parameters.source; - const source = sources[sourceId]; - - const geojsonString = await getGeoJSONDataFromLayerSource(source, model); - if (!geojsonString) { - return; - } - - downloadFile( - geojsonString, - `${exportFileName}.geojson`, - 'application/geo+json', - ); + return exportLayer(model, selectedLayer, formValues.exportFileName); }, }); commands.addCommand(CommandIDs.getGeolocation, { label: trans.__('Center on Geolocation'), - execute: async () => { - const viewModel = tracker.currentWidget?.model; - const options = { + describedBy: { + args: { + type: 'object', + properties: { + filePath: { type: 'string' }, + }, + }, + }, + + execute: async (args?: { filePath?: string }) => { + const { filePath } = args ?? {}; + + // Resolve widget once + const current = filePath + ? tracker.find(w => w.model.filePath === filePath) + : tracker.currentWidget; + + if (!current) { + console.warn('No document found'); + return; + } + + const viewModel = current.model; + + const options: PositionOptions = { enableHighAccuracy: true, timeout: 5000, maximumAge: 0, }; - const success = (pos: any) => { + + const success = (pos: GeolocationPosition) => { const location: Coordinate = fromLonLat([ pos.coords.longitude, pos.coords.latitude, ]); - const Jgislocation: JgisCoordinates = { + + const jgisLocation: JgisCoordinates = { x: location[0], y: location[1], }; - if (viewModel) { - viewModel.geolocationChanged.emit(Jgislocation); - } + + viewModel.geolocationChanged.emit(jgisLocation); }; - const error = (err: any) => { - console.warn(`ERROR(${err.code}): ${err.message}`); + + const error = (err: GeolocationPositionError) => { + console.warn(`Geolocation error (${err.code}): ${err.message}`); }; + navigator.geolocation.getCurrentPosition(success, error, options); }, icon: targetWithCenterIcon, @@ -864,6 +1234,12 @@ export function addCommands( // Panel visibility commands commands.addCommand(CommandIDs.toggleLeftPanel, { label: trans.__('Toggle Left Panel'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => { const current = tracker.currentWidget; @@ -891,6 +1267,12 @@ export function addCommands( commands.addCommand(CommandIDs.toggleRightPanel, { label: trans.__('Toggle Right Panel'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => { const current = tracker.currentWidget; @@ -919,6 +1301,12 @@ export function addCommands( // Left panel tabs commands.addCommand(CommandIDs.showLayersTab, { label: trans.__('Show Layers Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget @@ -941,6 +1329,12 @@ export function addCommands( commands.addCommand(CommandIDs.showStacBrowserTab, { label: trans.__('Show STAC Browser Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget @@ -963,6 +1357,12 @@ export function addCommands( commands.addCommand(CommandIDs.showFiltersTab, { label: trans.__('Show Filters Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget @@ -986,6 +1386,12 @@ export function addCommands( // Right panel tabs commands.addCommand(CommandIDs.showObjectPropertiesTab, { label: trans.__('Show Object Properties Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget @@ -1008,6 +1414,12 @@ export function addCommands( commands.addCommand(CommandIDs.showAnnotationsTab, { label: trans.__('Show Annotations Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget @@ -1030,6 +1442,12 @@ export function addCommands( commands.addCommand(CommandIDs.showIdentifyPanelTab, { label: trans.__('Show Identify Panel Tab'), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => Boolean(tracker.currentWidget), isToggled: () => tracker.currentWidget diff --git a/packages/base/src/commands/operationCommands.ts b/packages/base/src/commands/operationCommands.ts new file mode 100644 index 000000000..9346a7232 --- /dev/null +++ b/packages/base/src/commands/operationCommands.ts @@ -0,0 +1,317 @@ +import { IJupyterGISModel, IJGISLayer, IJGISSource } from '@jupytergis/schema'; +import { IRenderMime } from '@jupyterlab/rendermime'; +import { CommandRegistry } from '@lumino/commands'; +import { UUID } from '@lumino/coreutils'; + +import { JupyterGISTracker } from '../types'; + +export namespace LayerCreationCommandIDs { + export const newGeoJSONWithParams = 'jupytergis:newGeoJSONWithParams'; + export const newRasterWithParams = 'jupytergis:newRasterWithParams'; + export const newVectorTileWithParams = 'jupytergis:newVectorTileWithParams'; + export const newGeoParquetWithParams = 'jupytergis:newGeoParquetWithParams'; + export const newHillshadeWithParams = 'jupytergis:newHillshadeWithParams'; + export const newImageWithParams = 'jupytergis:newImageWithParams'; + export const newVideoWithParams = 'jupytergis:newVideoWithParams'; + export const newGeoTiffWithParams = 'jupytergis:newGeoTiffWithParams'; + export const newShapefileWithParams = 'jupytergis:newShapefileWithParams'; +} + +type LayerCreationSpec = { + id: string; + label: string; + caption: string; + sourceType: string; + layerType: string; + sourceSchema: Record; + layerParamsSchema: Record; + buildParameters: (params: any, sourceId: string) => IJGISLayer['parameters']; +}; + +/** + * Generic command factory for layer creation. + */ +function createLayerCommand( + commands: CommandRegistry, + tracker: JupyterGISTracker, + trans: IRenderMime.TranslationBundle, + spec: LayerCreationSpec, +): void { + commands.addCommand(spec.id, { + label: trans.__(spec.label), + caption: trans.__(spec.caption), + isEnabled: () => true, + describedBy: { + args: { + type: 'object', + required: ['filePath', 'name', 'parameters'], + properties: { + filePath: { type: 'string', description: 'Path to the .jGIS file' }, + name: { type: 'string', description: 'Layer name' }, + parameters: { + type: 'object', + properties: { + source: spec.sourceSchema, + ...spec.layerParamsSchema, + }, + }, + } as any, + }, + }, + execute: (async (args: { + filePath: string; + name: string; + parameters: Record; + }) => { + const { filePath, name, parameters } = args; + const current = tracker.find(w => w.model.filePath === filePath); + if (!current || !current.model.sharedModel.editable) { + console.warn('Invalid or non-editable document for', filePath); + return; + } + + const model: IJupyterGISModel = current.model; + const sharedModel = model.sharedModel; + const sourceId = UUID.uuid4(); + const layerId = UUID.uuid4(); + + const sourceModel: IJGISSource = { + type: spec.sourceType as any, + name: `${name} Source`, + parameters: parameters.source, + }; + sharedModel.addSource(sourceId, sourceModel); + + const layerModel: IJGISLayer = { + type: spec.layerType as any, + name: name, + visible: true, + parameters: spec.buildParameters(parameters, sourceId), + }; + model.addLayer(layerId, layerModel); + }) as any, + }); +} + +/** + * Register all layer creation commands using declarative specs. + */ +export function addLayerCreationCommands(options: { + tracker: JupyterGISTracker; + commands: CommandRegistry; + trans: IRenderMime.TranslationBundle; +}): void { + const { tracker, commands, trans } = options; + + const specs: LayerCreationSpec[] = [ + { + id: LayerCreationCommandIDs.newGeoJSONWithParams, + label: 'New GeoJSON Layer From Parameters', + caption: 'Create a GeoJSON vector layer from a file path or URL', + sourceType: 'GeoJSONSource', + layerType: 'VectorLayer', + sourceSchema: { + type: 'object', + required: ['path'], + properties: { path: { type: 'string' } }, + }, + layerParamsSchema: { + color: { type: 'object' }, + opacity: { type: 'number', default: 1 }, + symbologyState: { type: 'object' }, + }, + buildParameters: (p, id) => ({ + source: id, + color: p.color ?? {}, + opacity: p.opacity ?? 1, + symbologyState: p.symbologyState, + }), + }, + { + id: LayerCreationCommandIDs.newRasterWithParams, + label: 'New Raster Layer From Parameters', + caption: 'Create a raster layer from a file path or URL', + sourceType: 'RasterSource', + layerType: 'RasterLayer', + sourceSchema: { + type: 'object', + required: ['url'], + properties: { url: { type: 'string' } }, + }, + layerParamsSchema: { opacity: { type: 'number', default: 1 } }, + buildParameters: (p, id) => ({ + source: id, + opacity: p.opacity ?? 1, + }), + }, + { + id: LayerCreationCommandIDs.newVectorTileWithParams, + label: 'New Vector Tile Layer From Parameters', + caption: 'Create a vector tile layer from a URL', + sourceType: 'VectorTileSource', + layerType: 'VectorTileLayer', + sourceSchema: { + type: 'object', + required: ['url'], + properties: { url: { type: 'string' } }, + }, + layerParamsSchema: { + color: { type: 'object' }, + opacity: { type: 'number', default: 1 }, + }, + buildParameters: (p, id) => ({ + source: id, + color: p.color ?? {}, + opacity: p.opacity ?? 1, + }), + }, + { + id: LayerCreationCommandIDs.newGeoParquetWithParams, + label: 'New GeoParquet Layer From Parameters', + caption: 'Create a GeoParquet vector layer from a file path or URL', + sourceType: 'GeoParquetSource', + layerType: 'VectorLayer', + sourceSchema: { + type: 'object', + required: ['path'], + properties: { path: { type: 'string' } }, + }, + layerParamsSchema: { + color: { type: 'object' }, + opacity: { type: 'number', default: 1 }, + symbologyState: { type: 'object' }, + }, + buildParameters: (p, id) => ({ + source: id, + color: p.color ?? {}, + opacity: p.opacity ?? 1, + symbologyState: p.symbologyState, + }), + }, + { + id: LayerCreationCommandIDs.newHillshadeWithParams, + label: 'New Hillshade Layer From Parameters', + caption: 'Create a hillshade layer from a DEM raster source', + sourceType: 'RasterDemSource', + layerType: 'HillshadeLayer', + sourceSchema: { + type: 'object', + required: ['url'], + properties: { url: { type: 'string' } }, + }, + layerParamsSchema: { + shadowColor: { type: 'string', default: '#473B24' }, + }, + buildParameters: (p, id) => ({ + source: id, + shadowColor: p.shadowColor ?? '#473B24', + }), + }, + { + id: LayerCreationCommandIDs.newImageWithParams, + label: 'New Image Layer From Parameters', + caption: 'Create an image layer from afile path or URL', + sourceType: 'ImageSource', + layerType: 'ImageLayer', + sourceSchema: { + type: 'object', + required: ['path', 'coordinates'], + properties: { + path: { type: 'string' }, + coordinates: { + type: 'array', + items: { type: 'array', items: { type: 'number' } }, + }, + }, + }, + layerParamsSchema: { opacity: { type: 'number', default: 1 } }, + buildParameters: (p, id) => ({ + source: id, + opacity: p.opacity ?? 1, + }), + }, + { + id: LayerCreationCommandIDs.newVideoWithParams, + label: 'New Video Layer From Parameters', + caption: 'Create a video layer from a file path or URL', + sourceType: 'VideoSource', + layerType: 'RasterLayer', + sourceSchema: { + type: 'object', + required: ['urls', 'coordinates'], + properties: { + urls: { type: 'array', items: { type: 'string' } }, + coordinates: { + type: 'array', + items: { type: 'array', items: { type: 'number' } }, + }, + }, + }, + layerParamsSchema: { opacity: { type: 'number', default: 1 } }, + buildParameters: (p, id) => ({ + source: id, + opacity: p.opacity ?? 1, + }), + }, + { + id: LayerCreationCommandIDs.newGeoTiffWithParams, + label: 'New GeoTIFF Layer From Parameters', + caption: 'Create a GeoTIFF layer from a file path or URL', + sourceType: 'GeoTiffSource', + layerType: 'WebGlLayer', + sourceSchema: { + type: 'object', + required: ['urls'], + properties: { + urls: { + type: 'array', + items: { + type: 'object', + properties: { + url: { type: 'string' }, + min: { type: 'number' }, + max: { type: 'number' }, + }, + }, + }, + }, + }, + layerParamsSchema: { + opacity: { type: 'number', default: 1 }, + color: { type: 'any' }, + symbologyState: { type: 'object' }, + }, + buildParameters: (p, id) => ({ + source: id, + opacity: p.opacity ?? 1, + color: p.color, + symbologyState: p.symbologyState ?? { renderType: 'continuous' }, + }), + }, + { + id: LayerCreationCommandIDs.newShapefileWithParams, + label: 'New Shapefile Layer From Parameters', + caption: 'Create a Shapefile vector layer from a file path or URL', + sourceType: 'ShapefileSource', + layerType: 'VectorLayer', + sourceSchema: { + type: 'object', + required: ['path'], + properties: { path: { type: 'string' } }, + }, + layerParamsSchema: { + color: { type: 'object' }, + opacity: { type: 'number', default: 1 }, + symbologyState: { type: 'object' }, + }, + buildParameters: (p, id) => ({ + source: id, + color: p.color ?? {}, + opacity: p.opacity ?? 1, + symbologyState: p.symbologyState ?? { renderType: 'Single Symbol' }, + }), + }, + ]; + + specs.forEach(spec => createLayerCommand(commands, tracker, trans, spec)); +} diff --git a/packages/base/src/commands/processingCommandsFromParams.ts b/packages/base/src/commands/processingCommandsFromParams.ts new file mode 100644 index 000000000..099846215 --- /dev/null +++ b/packages/base/src/commands/processingCommandsFromParams.ts @@ -0,0 +1,161 @@ +import { + IJGISFormSchemaRegistry, + ProcessingLogicType, + ProcessingType, + ProcessingMerge, + IJGISLayer, + IJGISSource, +} from '@jupytergis/schema'; +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { CommandRegistry } from '@lumino/commands'; +import { UUID } from '@lumino/coreutils'; + +import { getGdal } from '../gdal'; +import { getLayerGeoJSON } from '../processing'; +import { replaceInSql } from '../processing/processingCommands'; +import { JupyterGISTracker } from '../types'; + +/** + * Execute processing directly from params (no UI dialogs). + */ +async function processLayerFromParams( + tracker: JupyterGISTracker, + processingType: ProcessingType, + options: { + sqlQueryFn: (layerName: string, param: any) => string; + gdalFunction: 'ogr2ogr' | 'gdal_rasterize' | 'gdalwarp' | 'gdal_translate'; + gdalOptions: (sqlQuery: string) => string[]; + }, + app: JupyterFrontEnd, + filePath: string, + params: Record, +): Promise { + const current = tracker.find(w => w.model.filePath === filePath); + if (!current) { + return; + } + + const model = current.model; + const { sources = {}, layers = {} } = model.sharedModel; + const inputLayerId = params.inputLayer; + const inputLayer = layers[inputLayerId]; + if (!inputLayer) { + return; + } + + const geojsonString = await getLayerGeoJSON(inputLayer, sources, model); + if (!geojsonString) { + return; + } + + const Gdal = await getGdal(); + const fileBlob = new Blob([geojsonString], { type: 'application/geo+json' }); + const geoFile = new File([fileBlob], 'input.geojson', { + type: 'application/geo+json', + }); + + const result = await Gdal.open(geoFile); + const dataset = result.datasets[0] as any; + const layerName = dataset.info.layers[0].name; + + const sqlQuery = options.sqlQueryFn(layerName, params); + const fullOptions = options.gdalOptions(sqlQuery); + + const outputFilePath = await Gdal.ogr2ogr(dataset, fullOptions); + const processedBytes = await Gdal.getFileBytes(outputFilePath); + Gdal.close(dataset); + + const processedGeoJSON = JSON.parse(new TextDecoder().decode(processedBytes)); + const newSourceId = UUID.uuid4(); + + const sourceModel: IJGISSource = { + type: 'GeoJSONSource', + name: `${processingType} Output`, + parameters: { data: processedGeoJSON }, + }; + + const layerModel: IJGISLayer = { + type: 'VectorLayer', + name: `${processingType} Layer`, + visible: true, + parameters: { source: newSourceId }, + }; + + model.sharedModel.addSource(newSourceId, sourceModel); + model.addLayer(UUID.uuid4(), layerModel); +} + +/** + * Register all processing commands from schema + ProcessingMerge metadata. + */ +export function addProcessingCommandsFromParams(options: { + app: JupyterFrontEnd; + commands: CommandRegistry; + tracker: JupyterGISTracker; + trans: any; + formSchemaRegistry: IJGISFormSchemaRegistry; + processingSchemas: Record; +}): void { + const { app, commands, tracker, trans, processingSchemas } = options; + + for (const proc of ProcessingMerge) { + if (proc.type !== ProcessingLogicType.vector) { + continue; + } + + const schemaKey = Object.keys(processingSchemas).find( + k => k.toLowerCase() === proc.name.toLowerCase(), + ); + const schema = schemaKey ? processingSchemas[schemaKey] : undefined; + if (!schema) { + continue; + } + + const commandId = `${proc.name}WithParams`; + + commands.addCommand(commandId, { + label: trans.__(`${proc.label} from params`), + isEnabled: () => true, + describedBy: { + args: { + type: 'object', + required: ['filePath', 'params'], + properties: { + filePath: { + type: 'string', + description: 'Path to the .jGIS file', + }, + params: schema, + }, + }, + }, + execute: (async (args: { + filePath: string; + params: Record; + }) => { + const { filePath, params } = args; + await processLayerFromParams( + tracker, + proc.name as ProcessingType, + { + sqlQueryFn: (layer, p) => + replaceInSql(proc.operations.sql, p, layer), + gdalFunction: 'ogr2ogr', + gdalOptions: (sql: string) => [ + '-f', + 'GeoJSON', + '-dialect', + 'SQLITE', + '-sql', + sql, + 'output.geojson', + ], + }, + app, + filePath, + params, + ); + }) as any, + }); + } +} diff --git a/packages/base/src/constants.ts b/packages/base/src/constants.ts index 664f09ea5..3ecae0fe7 100644 --- a/packages/base/src/constants.ts +++ b/packages/base/src/constants.ts @@ -44,15 +44,15 @@ const iconObject = { [CommandIDs.redo]: { icon: redoIcon }, [CommandIDs.undo]: { icon: undoIcon }, [CommandIDs.openLayerBrowser]: { icon: bookOpenIcon }, - [CommandIDs.newRasterEntry]: { icon: rasterIcon }, - [CommandIDs.newVectorTileEntry]: { icon: vectorSquareIcon }, - [CommandIDs.newGeoJSONEntry]: { icon: geoJSONIcon }, - [CommandIDs.newHillshadeEntry]: { icon: moundIcon }, - [CommandIDs.newImageEntry]: { iconClass: 'fa fa-image' }, - [CommandIDs.newVideoEntry]: { iconClass: 'fa fa-video' }, - [CommandIDs.newShapefileEntry]: { iconClass: 'fa fa-file' }, - [CommandIDs.newGeoTiffEntry]: { iconClass: 'fa fa-image' }, - [CommandIDs.newGeoParquetEntry]: { iconClass: 'fa fa-file' }, + [CommandIDs.openNewRasterDialog]: { icon: rasterIcon }, + [CommandIDs.openNewVectorTileDialog]: { icon: vectorSquareIcon }, + [CommandIDs.openNewGeoJSONDialog]: { icon: geoJSONIcon }, + [CommandIDs.openNewHillshadeDialog]: { icon: moundIcon }, + [CommandIDs.openNewImageDialog]: { iconClass: 'fa fa-image' }, + [CommandIDs.openNewVideoDialog]: { iconClass: 'fa fa-video' }, + [CommandIDs.openNewShapefileDialog]: { iconClass: 'fa fa-file' }, + [CommandIDs.openNewGeoTiffDialog]: { iconClass: 'fa fa-image' }, + [CommandIDs.openNewGeoParquetDialog]: { iconClass: 'fa fa-file' }, [CommandIDs.symbology]: { iconClass: 'fa fa-brush' }, [CommandIDs.identify]: { icon: infoIcon }, [CommandIDs.temporalController]: { icon: clockIcon }, diff --git a/packages/base/src/mainview/mainView.tsx b/packages/base/src/mainview/mainView.tsx index 727027003..33a70fc63 100644 --- a/packages/base/src/mainview/mainView.tsx +++ b/packages/base/src/mainview/mainView.tsx @@ -607,6 +607,16 @@ export class MainView extends React.Component { addContextMenu = (): void => { this._commands.addCommand(CommandIDs.addAnnotation, { + label: 'Add annotation', + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, + isEnabled: () => { + return !!this._Map; + }, execute: () => { if (!this._Map) { return; @@ -624,10 +634,6 @@ export class MainView extends React.Component { open: true, }); }, - label: 'Add annotation', - isEnabled: () => { - return !!this._Map; - }, }); this._contextMenu.addItem({ diff --git a/packages/base/src/menus.ts b/packages/base/src/menus.ts index 709552402..2c5760cc8 100644 --- a/packages/base/src/menus.ts +++ b/packages/base/src/menus.ts @@ -13,22 +13,22 @@ export const vectorSubMenu = (commands: CommandRegistry) => { subMenu.addItem({ type: 'command', - command: CommandIDs.newVectorTileEntry, + command: CommandIDs.openNewVectorTileDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newGeoJSONEntry, + command: CommandIDs.openNewGeoJSONDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newShapefileEntry, + command: CommandIDs.openNewShapefileDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newGeoParquetEntry, + command: CommandIDs.openNewGeoParquetDialog, }); return subMenu; @@ -43,22 +43,22 @@ export const rasterSubMenu = (commands: CommandRegistry) => { subMenu.addItem({ type: 'command', - command: CommandIDs.newRasterEntry, + command: CommandIDs.openNewRasterDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newHillshadeEntry, + command: CommandIDs.openNewHillshadeDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newImageEntry, + command: CommandIDs.openNewImageDialog, }); subMenu.addItem({ type: 'command', - command: CommandIDs.newGeoTiffEntry, + command: CommandIDs.openNewGeoTiffDialog, }); return subMenu; diff --git a/packages/base/src/processing/processingCommands.ts b/packages/base/src/processing/processingCommands.ts index a5b613928..227c5f075 100644 --- a/packages/base/src/processing/processingCommands.ts +++ b/packages/base/src/processing/processingCommands.ts @@ -51,6 +51,12 @@ export function addProcessingCommands( if (processingElement.type === ProcessingLogicType.vector) { commands.addCommand(processingElement.name, { label: trans.__(processingElement.label), + describedBy: { + args: { + type: 'object', + properties: {}, + }, + }, isEnabled: () => selectedLayerIsOfType(['VectorLayer'], tracker), execute: async () => { await processSelectedLayer( diff --git a/python/jupytergis_core/src/jgisplugin/plugins.ts b/python/jupytergis_core/src/jgisplugin/plugins.ts index e2b9df013..5dc373aed 100644 --- a/python/jupytergis_core/src/jgisplugin/plugins.ts +++ b/python/jupytergis_core/src/jgisplugin/plugins.ts @@ -157,6 +157,22 @@ const activate = async ( app.commands.addCommand(CommandIDs.createNew, { label: args => (args['label'] as string) ?? 'GIS Project', + describedBy: { + args: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'The label for the file creation command', + }, + cwd: { + type: 'string', + description: + 'The current working directory where the file should be created', + }, + }, + }, + }, caption: 'Create a new JGIS Editor', icon: args => logoIcon, execute: async args => { @@ -211,22 +227,22 @@ const activate = async ( // Layers and Sources palette.addItem({ - command: CommandIDs.newRasterEntry, + command: CommandIDs.openNewRasterDialog, category: 'JupyterGIS', }); palette.addItem({ - command: CommandIDs.newVectorTileEntry, + command: CommandIDs.openNewVectorTileDialog, category: 'JupyterGIS', }); palette.addItem({ - command: CommandIDs.newGeoJSONEntry, + command: CommandIDs.openNewGeoJSONDialog, category: 'JupyterGIS', }); palette.addItem({ - command: CommandIDs.newHillshadeEntry, + command: CommandIDs.openNewHillshadeDialog, category: 'JupyterGIS', }); diff --git a/python/jupytergis_qgis/src/plugins.ts b/python/jupytergis_qgis/src/plugins.ts index 4294975b5..232ea56ae 100644 --- a/python/jupytergis_qgis/src/plugins.ts +++ b/python/jupytergis_qgis/src/plugins.ts @@ -220,6 +220,18 @@ const activate = async ( if (installed) { app.commands.addCommand(CommandIDs.exportQgis, { label: 'Export To QGZ', + describedBy: { + args: { + type: 'object', + properties: { + filepath: { + type: 'string', + description: + 'Optional. Destination filename (with or without .qgz extension) for the exported QGIS project.', + }, + }, + }, + }, isEnabled: () => tracker.currentWidget ? tracker.currentWidget.model.sharedModel.editable diff --git a/ui-tests/tests/contextmenu.spec.ts b/ui-tests/tests/contextmenu.spec.ts index a294313df..5eb32c381 100644 --- a/ui-tests/tests/contextmenu.spec.ts +++ b/ui-tests/tests/contextmenu.spec.ts @@ -92,7 +92,9 @@ test.describe('context menu', () => { }); await page.getByText('Add Layer').hover(); await page.getByText('Add Raster Layer', { exact: true }).hover(); - await page.getByText('New Raster Tile Layer', { exact: true }).click(); + await page + .getByText('Open New Raster Tile Dialog', { exact: true }) + .click(); await page .getByRole('dialog') diff --git a/ui-tests/tests/geojson-layers.spec.ts b/ui-tests/tests/geojson-layers.spec.ts index bc2ac7edd..35baa4b23 100644 --- a/ui-tests/tests/geojson-layers.spec.ts +++ b/ui-tests/tests/geojson-layers.spec.ts @@ -54,7 +54,7 @@ test.describe('#geoJSONLayer', () => { await page.getByText('Add Vector Layer').hover(); await page .locator('#jp-gis-toolbar-vector-menu') - .getByText('New GeoJSON layer') + .getByText('Open New GeoJSON Dialog') .click(); const dialog = page.locator('.jp-Dialog-content');