Skip to content

Commit 9a1c11b

Browse files
authored
Enable downloading a vectorlayer (#528)
* Enable exporting and downloading a layer * make geotiff work * let user decide the name * just geojson and tif for now * schema for exportlayer * cleanup and refactoring * Major Refactor * reuse methods in buffer * lint * rename formdialog * more refactoring * rename method * check sourcetype * lint * one more check * Remove download as geotiff for now * rename getselectedlayer and add check for selected layer
1 parent 28c2809 commit 9a1c11b

File tree

8 files changed

+224
-57
lines changed

8 files changed

+224
-57
lines changed

packages/base/src/commands.ts

+104-55
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import keybindings from './keybindings.json';
2424
import { JupyterGISTracker } from './types';
2525
import { JupyterGISDocumentWidget } from './widget';
2626
import { getGdal } from './gdal';
27-
import { loadFile } from './tools';
27+
import { getGeoJSONDataFromLayerSource, downloadFile } from './tools';
2828
import { IJGISLayer, IJGISSource } from '@jupytergis/schema';
2929
import { UUID } from '@lumino/coreutils';
30-
import { ProcessingFormDialog } from './formbuilder/processingformdialog';
30+
import { FormDialog } from './formbuilder/formdialog';
3131

3232
interface ICreateEntry {
3333
tracker: JupyterGISTracker;
@@ -51,6 +51,34 @@ function loadKeybindings(commands: CommandRegistry, keybindings: any[]) {
5151
});
5252
}
5353

54+
/**
55+
* Get the currently selected layer from the shared model. Returns null if there is no selection or multiple layer is selected.
56+
*/
57+
function getSingleSelectedLayer(tracker: JupyterGISTracker): IJGISLayer | null {
58+
const model = tracker.currentWidget?.model as IJupyterGISModel;
59+
if (!model) {
60+
return null;
61+
}
62+
63+
const localState = model.sharedModel.awareness.getLocalState();
64+
if (!localState || !localState['selected']?.value) {
65+
return null;
66+
}
67+
68+
const selectedLayers = Object.keys(localState['selected'].value);
69+
70+
// Ensure only one layer is selected
71+
if (selectedLayers.length !== 1) {
72+
return null;
73+
}
74+
75+
const selectedLayerId = selectedLayers[0];
76+
const layers = model.sharedModel.layers ?? {};
77+
const selectedLayer = layers[selectedLayerId];
78+
79+
return selectedLayer && selectedLayer.parameters ? selectedLayer : null;
80+
}
81+
5482
/**
5583
* Add the commands to the application's command registry.
5684
*/
@@ -293,33 +321,18 @@ export function addCommands(
293321
commands.addCommand(CommandIDs.buffer, {
294322
label: trans.__('Buffer'),
295323
isEnabled: () => {
296-
const model = tracker.currentWidget?.model;
297-
const localState = model?.sharedModel.awareness.getLocalState();
298-
299-
if (!model || !localState || !localState['selected']?.value) {
300-
return false;
301-
}
302-
303-
const selectedLayers = localState['selected'].value;
304-
305-
if (Object.keys(selectedLayers).length > 1) {
306-
return false;
307-
}
308-
309-
const layerId = Object.keys(selectedLayers)[0];
310-
const layer = model.getLayer(layerId);
311-
312-
if (!layer) {
324+
const selectedLayer = getSingleSelectedLayer(tracker);
325+
if (!selectedLayer) {
313326
return false;
314327
}
315-
316-
const isValidLayer = ['VectorLayer', 'ShapefileLayer'].includes(
317-
layer.type
318-
);
319-
320-
return isValidLayer;
328+
return ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type);
321329
},
322330
execute: async () => {
331+
const selected = getSingleSelectedLayer(tracker);
332+
if (!selected) {
333+
console.error('No valid selected layer.');
334+
return;
335+
}
323336
const layers = tracker.currentWidget?.model.sharedModel.layers ?? {};
324337
const sources = tracker.currentWidget?.model.sharedModel.sources ?? {};
325338

@@ -337,10 +350,10 @@ export function addCommands(
337350

338351
// Open form and get user input
339352
const formValues = await new Promise<IDict>(resolve => {
340-
const dialog = new ProcessingFormDialog({
353+
const dialog = new FormDialog({
341354
title: 'Buffer',
342355
schema: schema,
343-
model: tracker.currentWidget?.model as IJupyterGISModel,
356+
model: model,
344357
sourceData: {
345358
inputLayer: selectedLayerId,
346359
bufferDistance: 10,
@@ -372,30 +385,14 @@ export function addCommands(
372385
const sourceId = inputLayer.parameters.source;
373386
const source = sources[sourceId];
374387

375-
if (!source.parameters) {
388+
if (!source || !source.parameters) {
376389
console.error(`Source with ID ${sourceId} not found or missing path.`);
377390
return;
378391
}
379392

380-
let geojsonString: string;
381-
382-
if (source.parameters.path) {
383-
const fileContent = await loadFile({
384-
filepath: source.parameters.path,
385-
type: source.type,
386-
model: tracker.currentWidget?.model as IJupyterGISModel
387-
});
388-
389-
geojsonString =
390-
typeof fileContent === 'object'
391-
? JSON.stringify(fileContent)
392-
: fileContent;
393-
} else if (source.parameters.data) {
394-
geojsonString = JSON.stringify(source.parameters.data);
395-
} else {
396-
throw new Error(
397-
`Source ${sourceId} is missing both 'path' and 'data' parameters.`
398-
);
393+
const geojsonString = await getGeoJSONDataFromLayerSource(source, model);
394+
if (!geojsonString) {
395+
return;
399396
}
400397

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

447444
const layerModel: IJGISLayer = {
448445
type: 'VectorLayer',
449-
parameters: {
450-
source: newSourceId
451-
},
446+
parameters: { source: newSourceId },
452447
visible: true,
453448
name: inputLayer.name + ' Buffer'
454449
};
455450

456-
tracker.currentWidget?.model.sharedModel.addSource(
457-
newSourceId,
458-
sourceModel
459-
);
460-
tracker.currentWidget?.model.addLayer(UUID.uuid4(), layerModel);
451+
model.sharedModel.addSource(newSourceId, sourceModel);
452+
model.addLayer(UUID.uuid4(), layerModel);
461453
}
462454
}
463455
});
@@ -1167,6 +1159,63 @@ export function addCommands(
11671159
}
11681160
});
11691161

1162+
commands.addCommand(CommandIDs.downloadGeoJSON, {
1163+
label: trans.__('Download as GeoJSON'),
1164+
isEnabled: () => {
1165+
const selectedLayer = getSingleSelectedLayer(tracker);
1166+
return selectedLayer
1167+
? ['VectorLayer', 'ShapefileLayer'].includes(selectedLayer.type)
1168+
: false;
1169+
},
1170+
execute: async () => {
1171+
const selectedLayer = getSingleSelectedLayer(tracker);
1172+
if (!selectedLayer) {
1173+
return;
1174+
}
1175+
const model = tracker.currentWidget?.model as IJupyterGISModel;
1176+
const sources = model.sharedModel.sources ?? {};
1177+
1178+
const exportSchema = {
1179+
...(formSchemaRegistry.getSchemas().get('ExportGeoJSONSchema') as IDict)
1180+
};
1181+
1182+
const formValues = await new Promise<IDict>(resolve => {
1183+
const dialog = new FormDialog({
1184+
title: 'Download GeoJSON',
1185+
schema: exportSchema,
1186+
model,
1187+
sourceData: { exportFormat: 'GeoJSON' },
1188+
cancelButton: false,
1189+
syncData: (props: IDict) => {
1190+
resolve(props);
1191+
dialog.dispose();
1192+
}
1193+
});
1194+
1195+
dialog.launch();
1196+
});
1197+
1198+
if (!formValues || !selectedLayer.parameters) {
1199+
return;
1200+
}
1201+
1202+
const exportFileName = formValues.exportFileName;
1203+
const sourceId = selectedLayer.parameters.source;
1204+
const source = sources[sourceId];
1205+
1206+
const geojsonString = await getGeoJSONDataFromLayerSource(source, model);
1207+
if (!geojsonString) {
1208+
return;
1209+
}
1210+
1211+
downloadFile(
1212+
geojsonString,
1213+
`${exportFileName}.geojson`,
1214+
'application/geo+json'
1215+
);
1216+
}
1217+
});
1218+
11701219
loadKeybindings(commands, keybindings);
11711220
}
11721221

packages/base/src/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export namespace CommandIDs {
6969
// Map Commands
7070
export const addAnnotation = 'jupytergis:addAnnotation';
7171
export const zoomToLayer = 'jupytergis:zoomToLayer';
72+
export const downloadGeoJSON = 'jupytergis:downloadGeoJSON';
7273
}
7374

7475
interface IRegisteredIcon {

packages/base/src/formbuilder/processingformdialog.tsx packages/base/src/formbuilder/formdialog.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface IFormDialogOptions {
2222
model: IJupyterGISModel;
2323
}
2424

25-
export class ProcessingFormDialog extends Dialog<IDict> {
25+
export class FormDialog extends Dialog<IDict> {
2626
constructor(options: IFormDialogOptions) {
2727
let cancelCallback: (() => void) | undefined = undefined;
2828
if (options.cancelButton) {

packages/base/src/tools.ts

+51-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
IJGISOptions,
1515
IJGISSource,
1616
IJupyterGISModel,
17-
IRasterLayerGalleryEntry
17+
IRasterLayerGalleryEntry,
18+
SourceType
1819
} from '@jupytergis/schema';
1920
import RASTER_LAYER_GALLERY from '../rasterlayer_gallery/raster_layer_gallery.json';
2021

@@ -805,3 +806,52 @@ export const getNumericFeatureAttributes = (
805806

806807
return filteredRecord;
807808
};
809+
810+
export function downloadFile(
811+
content: BlobPart,
812+
fileName: string,
813+
mimeType: string
814+
) {
815+
const blob = new Blob([content], { type: mimeType });
816+
const url = URL.createObjectURL(blob);
817+
const downloadLink = document.createElement('a');
818+
downloadLink.href = url;
819+
downloadLink.download = fileName;
820+
document.body.appendChild(downloadLink);
821+
downloadLink.click();
822+
document.body.removeChild(downloadLink);
823+
}
824+
825+
export async function getGeoJSONDataFromLayerSource(
826+
source: IJGISSource,
827+
model: IJupyterGISModel
828+
): Promise<string | null> {
829+
const vectorSourceTypes: SourceType[] = ['GeoJSONSource', 'ShapefileSource'];
830+
831+
if (!vectorSourceTypes.includes(source.type as SourceType)) {
832+
console.error(
833+
`Invalid source type '${source.type}'. Expected one of: ${vectorSourceTypes.join(', ')}`
834+
);
835+
return null;
836+
}
837+
838+
if (!source.parameters) {
839+
console.error('Source parameters are missing.');
840+
return null;
841+
}
842+
843+
if (source.parameters.path) {
844+
const fileContent = await loadFile({
845+
filepath: source.parameters.path,
846+
type: source.type,
847+
model
848+
});
849+
return typeof fileContent === 'object'
850+
? JSON.stringify(fileContent)
851+
: fileContent;
852+
} else if (source.parameters.data) {
853+
return JSON.stringify(source.parameters.data);
854+
}
855+
console.error("Source is missing both 'path' and 'data' parameters.");
856+
return null;
857+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"type": "object",
3+
"description": "ExportGeoJSONSchema",
4+
"title": "IExportGeoJSON",
5+
"required": ["exportFileName"],
6+
"additionalProperties": false,
7+
"properties": {
8+
"exportFileName": {
9+
"type": "string",
10+
"title": "GeoJSON File Name",
11+
"default": "exported_layer",
12+
"description": "The name of the exported GeoJSON file."
13+
}
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"type": "object",
3+
"description": "ExportGeoTIFFSchema",
4+
"title": "IExportGeoTIFF",
5+
"required": ["exportFileName", "resolutionX", "resolutionY"],
6+
"additionalProperties": false,
7+
"properties": {
8+
"exportFileName": {
9+
"type": "string",
10+
"title": "GeoTiFF File Name",
11+
"default": "exported_layer",
12+
"description": "The name of the exported GeoTIFF file."
13+
},
14+
"resolutionX": {
15+
"type": "number",
16+
"title": "Resolution (Width)",
17+
"default": 1200,
18+
"minimum": 1,
19+
"maximum": 10000,
20+
"description": "The width resolution for the raster export."
21+
},
22+
"resolutionY": {
23+
"type": "number",
24+
"title": "Resolution (Height)",
25+
"default": 1200,
26+
"minimum": 1,
27+
"maximum": 10000,
28+
"description": "The height resolution for the raster export."
29+
}
30+
}
31+
}

packages/schema/src/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export * from './_interface/imageLayer';
2020
export * from './_interface/heatmapLayer';
2121
export * from './_interface/buffer';
2222

23+
// exportLayer
24+
export * from './_interface/exportGeojson';
25+
export * from './_interface/exportGeotiff';
26+
2327
// Other
2428
export * from './doc';
2529
export * from './interfaces';

python/jupytergis_lab/src/index.ts

+17
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,23 @@ const plugin: JupyterFrontEndPlugin<void> = {
169169
rank: 2
170170
});
171171

172+
// Create the Download submenu
173+
const downloadSubmenu = new Menu({ commands: app.commands });
174+
downloadSubmenu.title.label = translator.load('jupyterlab').__('Download');
175+
downloadSubmenu.id = 'jp-gis-contextmenu-download';
176+
177+
downloadSubmenu.addItem({
178+
command: CommandIDs.downloadGeoJSON
179+
});
180+
181+
// Add the Download submenu to the context menu
182+
app.contextMenu.addItem({
183+
type: 'submenu',
184+
selector: '.jp-gis-layerItem',
185+
rank: 2,
186+
submenu: downloadSubmenu
187+
});
188+
172189
// Create the Processing submenu
173190
const processingSubmenu = new Menu({ commands: app.commands });
174191
processingSubmenu.title.label = translator

0 commit comments

Comments
 (0)