Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 104 additions & 55 deletions packages/base/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import keybindings from './keybindings.json';
import { JupyterGISTracker } from './types';
import { JupyterGISDocumentWidget } from './widget';
import { getGdal } from './gdal';
import { loadFile } from './tools';
import { getGeoJSONDataFromLayerSource, downloadFile } from './tools';
import { IJGISLayer, IJGISSource } from '@jupytergis/schema';
import { UUID } from '@lumino/coreutils';
import { ProcessingFormDialog } from './formbuilder/processingformdialog';
import { FormDialog } from './formbuilder/formdialog';

interface ICreateEntry {
tracker: JupyterGISTracker;
Expand All @@ -51,6 +51,34 @@ function loadKeybindings(commands: CommandRegistry, keybindings: any[]) {
});
}

/**
* Get the currently selected layer from the shared model. Returns null if there is no selection or multiple layer is selected.
*/
function getSingleSelectedLayer(tracker: JupyterGISTracker): IJGISLayer | null {
const model = tracker.currentWidget?.model as IJupyterGISModel;
if (!model) {
return null;
}

const localState = model.sharedModel.awareness.getLocalState();
if (!localState || !localState['selected']?.value) {
return null;
}

const selectedLayers = Object.keys(localState['selected'].value);

// Ensure only one layer is selected
if (selectedLayers.length !== 1) {
return null;
}

const selectedLayerId = selectedLayers[0];
const layers = model.sharedModel.layers ?? {};
const selectedLayer = layers[selectedLayerId];

return selectedLayer && selectedLayer.parameters ? selectedLayer : null;
}

/**
* Add the commands to the application's command registry.
*/
Expand Down Expand Up @@ -293,33 +321,18 @@ export function addCommands(
commands.addCommand(CommandIDs.buffer, {
label: trans.__('Buffer'),
isEnabled: () => {
const model = tracker.currentWidget?.model;
const localState = model?.sharedModel.awareness.getLocalState();

if (!model || !localState || !localState['selected']?.value) {
return false;
}

const selectedLayers = localState['selected'].value;

if (Object.keys(selectedLayers).length > 1) {
return false;
}
Comment on lines -305 to -307
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have this logic anymore. How about putting this logic in getSelectedLayer and maybe rename getSelectedLayer into getSingleSelectedLayer (or something else that makes more sense if you have ideas)?


const layerId = Object.keys(selectedLayers)[0];
const layer = model.getLayer(layerId);

if (!layer) {
const selectedLayer = getSingleSelectedLayer(tracker);
if (!selectedLayer) {
return false;
}

const isValidLayer = ['VectorLayer', 'ShapefileLayer'].includes(
layer.type
);

return isValidLayer;
return ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type);
},
execute: async () => {
const selected = getSingleSelectedLayer(tracker);
if (!selected) {
console.error('No valid selected layer.');
return;
}
const layers = tracker.currentWidget?.model.sharedModel.layers ?? {};
const sources = tracker.currentWidget?.model.sharedModel.sources ?? {};

Expand All @@ -337,10 +350,10 @@ export function addCommands(

// Open form and get user input
const formValues = await new Promise<IDict>(resolve => {
const dialog = new ProcessingFormDialog({
const dialog = new FormDialog({
title: 'Buffer',
schema: schema,
model: tracker.currentWidget?.model as IJupyterGISModel,
model: model,
sourceData: {
inputLayer: selectedLayerId,
bufferDistance: 10,
Expand Down Expand Up @@ -372,30 +385,14 @@ export function addCommands(
const sourceId = inputLayer.parameters.source;
const source = sources[sourceId];

if (!source.parameters) {
if (!source || !source.parameters) {
console.error(`Source with ID ${sourceId} not found or missing path.`);
return;
}

let geojsonString: string;

if (source.parameters.path) {
const fileContent = await loadFile({
filepath: source.parameters.path,
type: source.type,
model: tracker.currentWidget?.model as IJupyterGISModel
});

geojsonString =
typeof fileContent === 'object'
? JSON.stringify(fileContent)
: fileContent;
} else if (source.parameters.data) {
geojsonString = JSON.stringify(source.parameters.data);
} else {
throw new Error(
`Source ${sourceId} is missing both 'path' and 'data' parameters.`
);
const geojsonString = await getGeoJSONDataFromLayerSource(source, model);
if (!geojsonString) {
return;
}

const fileBlob = new Blob([geojsonString], {
Expand Down Expand Up @@ -446,18 +443,13 @@ export function addCommands(

const layerModel: IJGISLayer = {
type: 'VectorLayer',
parameters: {
source: newSourceId
},
parameters: { source: newSourceId },
visible: true,
name: inputLayer.name + ' Buffer'
};

tracker.currentWidget?.model.sharedModel.addSource(
newSourceId,
sourceModel
);
tracker.currentWidget?.model.addLayer(UUID.uuid4(), layerModel);
model.sharedModel.addSource(newSourceId, sourceModel);
model.addLayer(UUID.uuid4(), layerModel);
}
}
});
Expand Down Expand Up @@ -1167,6 +1159,63 @@ export function addCommands(
}
});

commands.addCommand(CommandIDs.downloadGeoJSON, {
label: trans.__('Download as GeoJSON'),
isEnabled: () => {
const selectedLayer = getSingleSelectedLayer(tracker);
return selectedLayer
? ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type)
: false;
},
execute: async () => {
const selectedLayer = getSingleSelectedLayer(tracker);
if (!selectedLayer) {
return;
}
const model = tracker.currentWidget?.model as IJupyterGISModel;
const sources = model.sharedModel.sources ?? {};

const exportSchema = {
...(formSchemaRegistry.getSchemas().get('ExportGeoJSONSchema') as IDict)
};

const formValues = await new Promise<IDict>(resolve => {
const dialog = new FormDialog({
title: 'Download GeoJSON',
schema: exportSchema,
model,
sourceData: { exportFormat: 'GeoJSON' },
cancelButton: false,
syncData: (props: IDict) => {
resolve(props);
dialog.dispose();
}
});

dialog.launch();
});

if (!formValues || !selectedLayer.parameters) {
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'
);
}
});

loadKeybindings(commands, keybindings);
}

Expand Down
1 change: 1 addition & 0 deletions packages/base/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export namespace CommandIDs {
// Map Commands
export const addAnnotation = 'jupytergis:addAnnotation';
export const zoomToLayer = 'jupytergis:zoomToLayer';
export const downloadGeoJSON = 'jupytergis:downloadGeoJSON';
}

interface IRegisteredIcon {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface IFormDialogOptions {
model: IJupyterGISModel;
}

export class ProcessingFormDialog extends Dialog<IDict> {
export class FormDialog extends Dialog<IDict> {
constructor(options: IFormDialogOptions) {
let cancelCallback: (() => void) | undefined = undefined;
if (options.cancelButton) {
Expand Down
52 changes: 51 additions & 1 deletion packages/base/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
IJGISOptions,
IJGISSource,
IJupyterGISModel,
IRasterLayerGalleryEntry
IRasterLayerGalleryEntry,
SourceType
} from '@jupytergis/schema';
import RASTER_LAYER_GALLERY from '../rasterlayer_gallery/raster_layer_gallery.json';

Expand Down Expand Up @@ -805,3 +806,52 @@ export const getNumericFeatureAttributes = (

return filteredRecord;
};

export function downloadFile(
content: BlobPart,
fileName: string,
mimeType: string
) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}

export async function getGeoJSONDataFromLayerSource(
source: IJGISSource,
model: IJupyterGISModel
): Promise<string | null> {
const vectorSourceTypes: SourceType[] = ['GeoJSONSource', 'ShapefileSource'];

if (!vectorSourceTypes.includes(source.type as SourceType)) {
console.error(
`Invalid source type '${source.type}'. Expected one of: ${vectorSourceTypes.join(', ')}`
);
return null;
}

if (!source.parameters) {
console.error('Source parameters are missing.');
return null;
}

if (source.parameters.path) {
const fileContent = await loadFile({
filepath: source.parameters.path,
type: source.type,
model
});
return typeof fileContent === 'object'
? JSON.stringify(fileContent)
: fileContent;
} else if (source.parameters.data) {
return JSON.stringify(source.parameters.data);
}
console.error("Source is missing both 'path' and 'data' parameters.");
return null;
}
15 changes: 15 additions & 0 deletions packages/schema/src/schema/exportGeojson.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"type": "object",
"description": "ExportGeoJSONSchema",
"title": "IExportGeoJSON",
"required": ["exportFileName"],
"additionalProperties": false,
"properties": {
"exportFileName": {
"type": "string",
"title": "GeoJSON File Name",
"default": "exported_layer",
"description": "The name of the exported GeoJSON file."
}
}
}
31 changes: 31 additions & 0 deletions packages/schema/src/schema/exportGeotiff.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"type": "object",
"description": "ExportGeoTIFFSchema",
"title": "IExportGeoTIFF",
"required": ["exportFileName", "resolutionX", "resolutionY"],
"additionalProperties": false,
"properties": {
"exportFileName": {
"type": "string",
"title": "GeoTiFF File Name",
"default": "exported_layer",
"description": "The name of the exported GeoTIFF file."
},
"resolutionX": {
"type": "number",
"title": "Resolution (Width)",
"default": 1200,
"minimum": 1,
"maximum": 10000,
"description": "The width resolution for the raster export."
},
"resolutionY": {
"type": "number",
"title": "Resolution (Height)",
"default": 1200,
"minimum": 1,
"maximum": 10000,
"description": "The height resolution for the raster export."
}
}
}
4 changes: 4 additions & 0 deletions packages/schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export * from './_interface/imageLayer';
export * from './_interface/heatmapLayer';
export * from './_interface/buffer';

// exportLayer
export * from './_interface/exportGeojson';
export * from './_interface/exportGeotiff';

// Other
export * from './doc';
export * from './interfaces';
Expand Down
17 changes: 17 additions & 0 deletions python/jupytergis_lab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,23 @@ const plugin: JupyterFrontEndPlugin<void> = {
rank: 2
});

// Create the Download submenu
const downloadSubmenu = new Menu({ commands: app.commands });
downloadSubmenu.title.label = translator.load('jupyterlab').__('Download');
downloadSubmenu.id = 'jp-gis-contextmenu-download';

downloadSubmenu.addItem({
command: CommandIDs.downloadGeoJSON
});

// Add the Download submenu to the context menu
app.contextMenu.addItem({
type: 'submenu',
selector: '.jp-gis-layerItem',
rank: 2,
submenu: downloadSubmenu
});

// Create the Processing submenu
const processingSubmenu = new Menu({ commands: app.commands });
processingSubmenu.title.label = translator
Expand Down
Loading