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
63 changes: 63 additions & 0 deletions examples/geoparquet.jgis
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"layerTree": [
"9556ca29-a5ec-41af-bf14-4b543c52aafe",
"d7a2ad84-0750-4e9b-82c0-542fcc6b3265"
],
"layers": {
"9556ca29-a5ec-41af-bf14-4b543c52aafe": {
"name": "OpenStreetMap.Mapnik Layer",
"parameters": {
"source": "2a52082b-7992-40dc-92d6-75e309a1ed27"
},
"type": "RasterLayer",
"visible": true
},
"d7a2ad84-0750-4e9b-82c0-542fcc6b3265": {
"name": "Custom GeoParquet Layer",
"parameters": {
"opacity": 1.0,
"source": "c1da95b9-8a71-4fee-b4e3-6f0b5f53d2d4"
},
"type": "VectorLayer",
"visible": true
}
},
"metadata": {},
"options": {
"bearing": 0.0,
"extent": [
-20037508.342789244,
-15214174.147482341,
20037508.342789244,
15214174.147482341
],
"latitude": 0.0,
"longitude": 0.0,
"projection": "EPSG:3857",
"zoom": 1.5058115539195944
},
"schemaVersion": "0.5.0",
"sources": {
"2a52082b-7992-40dc-92d6-75e309a1ed27": {
"name": "OpenStreetMap.Mapnik",
"parameters": {
"attribution": "(C) OpenStreetMap contributors",
"maxZoom": 19.0,
"minZoom": 0.0,
"provider": "OpenStreetMap",
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"urlParameters": {}
},
"type": "RasterSource"
},
"c1da95b9-8a71-4fee-b4e3-6f0b5f53d2d4": {
"name": "Custom GeoParquet Source",
"parameters": {
"attribution": "",
"path": "https://raw.githubusercontent.com/opengeospatial/geoparquet/main/examples/example.parquet",
"projection": "EPSG:4326"
},
"type": "GeoParquetSource"
}
}
}
2 changes: 2 additions & 0 deletions packages/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@
"date-fns": "^4.1.0",
"gdal3.js": "^2.8.1",
"geojson-vt": "^4.0.2",
"geoparquet": "^0.3.0",
"geotiff": "^2.1.3",
"hyparquet-compressors": "^1.1.1",
"lucide-react": "^0.513.0",
"ol": "^10.1.0",
"ol-pmtiles": "^0.5.0",
Expand Down
1 change: 1 addition & 0 deletions packages/base/src/commands/BaseCommandIDs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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';

// Layer and group actions
export const renameLayer = 'jupytergis:renameLayer';
Expand Down
21 changes: 21 additions & 0 deletions packages/base/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,27 @@ export function addCommands(
...icons.get(CommandIDs.newVectorTileEntry),
});

commands.addCommand(CommandIDs.newGeoParquetEntry, {
label: trans.__('New GeoParquet Layer'),
isEnabled: () => {
return tracker.currentWidget
? tracker.currentWidget.model.sharedModel.editable
: false;
},
execute: Private.createEntry({
tracker,
formSchemaRegistry,
title: 'Create GeoParquet Layer',
createLayer: true,
createSource: true,
sourceData: { name: 'Custom GeoParquet Source' },
layerData: { name: 'Custom GeoParquet Layer' },
sourceType: 'GeoParquetSource',
layerType: 'VectorLayer',
}),
...icons.get(CommandIDs.newGeoParquetEntry),
});

commands.addCommand(CommandIDs.newGeoJSONEntry, {
label: trans.__('New GeoJSON layer'),
isEnabled: () => {
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 @@ -52,6 +52,7 @@ const iconObject = {
[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.symbology]: { iconClass: 'fa fa-brush' },
[CommandIDs.identify]: { icon: infoIcon },
[CommandIDs.temporalController]: { icon: clockIcon },
Expand Down
4 changes: 4 additions & 0 deletions packages/base/src/formbuilder/formselectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function getSourceTypeForm(
case 'VectorTileSource':
SourceForm = TileSourcePropertiesForm;
break;
case 'GeoParquetSource':
SourceForm = PathBasedSourcePropertiesForm;
break;

// ADD MORE FORM TYPES HERE
}
return SourceForm;
Expand Down
23 changes: 23 additions & 0 deletions packages/base/src/mainview/mainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
IVectorLayer,
IVectorTileLayer,
IVectorTileSource,
IGeoParquetSource,
IWebGlLayer,
JgisCoordinates,
JupyterGISModel,
Expand Down Expand Up @@ -769,6 +770,28 @@ export class MainView extends React.Component<IProps, IStates> {

break;
}

case 'GeoParquetSource': {
const parameters = source.parameters as IGeoParquetSource;

const geojson = await loadFile({
Copy link
Member

@mfisher87 mfisher87 Jun 12, 2025

Choose a reason for hiding this comment

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

I don't feel great about converting geoparquet to geojson as we lose all the performance advantages. Openlayers doesn't support geoparquet (yet? there is no open issue in their GitHub repo... should we make one?), so we need to convert to something... what about flatgeobuf?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll take a look at flatgeobuf, thanks!

Copy link
Member

Choose a reason for hiding this comment

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

@elifsu-simula have you had any luck on this?

I also see your PR has quite a lot of conflicts now (mainly due to the changes in imports I assume). I can help with this if needed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hi Martin! Sorry for the late reply. I have been on vacation. I am starting to look into this now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I was planning to convert GeoParquet files to FlatGeobuf using GDAL's ogr2ogr function, but our current gdal3.js WASM build doesn’t include the Parquet driver. I’ve been working on rebuilding it with the Parquet driver, but before investing more time I wanted to check if this approach is feasible or if I should look into other ways without modifying the original gdal3.js build.

Copy link
Member

Choose a reason for hiding this comment

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

Does OpenLayers support FlatGeobuf as-is?

If we start investigating making our own gdal WASM build, I'd rather prefer it being done on emscripten-forge, that way we could also use it in the Python kernel in JupyterLite if we also compile the Python bindings for it.

But that's probably not a straightforward task. I personally would be ok with the current state of the PR as a first solution, then track in an issue exploring other approaches.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

OpenLayers doesn't support FlatGeobuf, but the FlatGeobuf project has an OpenLayers integration (example) . Then I'll fix the conflicts and create an issue for this.

Copy link
Member

Choose a reason for hiding this comment

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

neat

filepath: parameters.path,
type: 'GeoParquetSource',
model: this._model,
});

const geojsonData = Array.isArray(geojson) ? geojson[0] : geojson;

const format = new GeoJSON();

newSource = new VectorSource({
features: format.readFeatures(geojsonData, {
dataProjection: parameters.projection,
featureProjection: this._Map.getView().getProjection(),
}),
});
break;
}
}

newSource.set('id', id);
Expand Down
5 changes: 5 additions & 0 deletions packages/base/src/menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const vectorSubMenu = (commands: CommandRegistry) => {
command: CommandIDs.newShapefileEntry,
});

subMenu.addItem({
type: 'command',
command: CommandIDs.newGeoParquetEntry,
});

return subMenu;
};

Expand Down
33 changes: 33 additions & 0 deletions packages/base/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PathExt, URLExt } from '@jupyterlab/coreutils';
import { Contents, ServerConnection } from '@jupyterlab/services';
import { VectorTile } from '@mapbox/vector-tile';
import * as d3Color from 'd3-color';
import { compressors } from 'hyparquet-compressors';
import Protobuf from 'pbf';
import shp from 'shpjs';

Expand Down Expand Up @@ -567,6 +568,26 @@ export const loadFile = async (fileInfo: {
throw new Error(`Failed to fetch ${filepath}`);
}

case 'GeoParquetSource': {
const cached = await getFromIndexedDB(filepath);
if (cached) {
return cached.file;
}

const { asyncBufferFromUrl, toGeoJson } = await import('geoparquet');

const file = await asyncBufferFromUrl({ url: filepath });
const geojson = await toGeoJson({ file });

if (geojson) {
await saveToIndexedDB(filepath, geojson);
return geojson;
}

showErrorMessage('Network error', `Failed to fetch ${filepath}`);
throw new Error(`Failed to fetch ${filepath}`);
}

default: {
throw new Error(`Unsupported URL handling for source type: ${type}`);
}
Expand Down Expand Up @@ -633,6 +654,18 @@ export const loadFile = async (fileInfo: {
}
}

case 'GeoParquetSource': {
if (typeof file.content === 'string') {
const { toGeoJson } = await import('geoparquet');

const arrayBuffer = await stringToArrayBuffer(file.content as string);

return await toGeoJson({ file: arrayBuffer, compressors });
} else {
throw new Error('Invalid file format for GeoParquet content.');
}
}

default: {
throw new Error(`Unsupported source type: ${type}`);
}
Expand Down
12 changes: 12 additions & 0 deletions packages/base/src/types/geoparquet.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare module 'geoparquet' {
export function asyncBufferFromUrl(options: {
url: string;
byteLength?: number;
requestInit?: RequestInit;
}): Promise<AsyncBuffer>;

export function toGeoJson(options: {
file: AsyncBuffer;
compressors?: any;
}): Promise<GeoJSON>;
};
3 changes: 2 additions & 1 deletion packages/base/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"src/**/*",
"src/schema/*.json",
"src/_interface/*.json",
"src/*.json"
"src/*.json",
"src/types/*"
]
}
3 changes: 2 additions & 1 deletion packages/schema/src/schema/project/jgis.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"VideoSource",
"ImageSource",
"ShapefileSource",
"GeoTiffSource"
"GeoTiffSource",
"GeoParquetSource"
]
},
"jGISLayer": {
Expand Down
24 changes: 24 additions & 0 deletions packages/schema/src/schema/project/sources/geoParquetSource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"type": "object",
"description": "GeoParquetSource",
"title": "IGeoParquetSource",
"required": ["path"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "The path to the GeoParquet source"
},
"attribution": {
"type": "string",
"readOnly": true,
"description": "The attribution for the GeoParquet source.",
"default": ""
},
"projection": {
"type": "string",
"description": "The projection information for the GeoParquet data (optional).",
"default": "EPSG:4326"
}
}
}
1 change: 1 addition & 0 deletions packages/schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './_interface/project/sources/rasterSource';
export * from './_interface/project/sources/shapefileSource';
export * from './_interface/project/sources/vectorTileSource';
export * from './_interface/project/sources/videoSource';
export * from './_interface/project/sources/geoParquetSource';

// Layers
export * from './_interface/project/layers/heatmapLayer';
Expand Down
1 change: 1 addition & 0 deletions python/jupytergis_core/jupytergis_core/schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .interfaces.project.sources.imageSource import IImageSource # noqa
from .interfaces.project.sources.geoTiffSource import IGeoTiffSource # noqa
from .interfaces.project.sources.rasterDemSource import IRasterDemSource # noqa
from .interfaces.project.sources.geoParquetSource import IGeoParquetSource # noqa

from .interfaces.processing.buffer import IBuffer # noqa

Expand Down
56 changes: 56 additions & 0 deletions python/jupytergis_lab/jupytergis_lab/notebook/gis_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from jupytergis_core.schema import (
IGeoJSONSource,
IGeoParquetSource,
IGeoTiffSource,
IHeatmapLayer,
IHillshadeLayer,
Expand Down Expand Up @@ -544,6 +545,59 @@ def add_heatmap_layer(

return self._add_layer(OBJECT_FACTORY.create_layer(layer, self))

def add_geoparquet_layer(
self,
path: str,
name: str = "GeoParquetLayer",
type: "circle" | "fill" | "line" = "line",
opacity: float = 1,
logical_op: str | None = None,
feature: str | None = None,
operator: str | None = None,
value: Union[str, int, float] | None = None,
color_expr=None,
):
"""
Add a GeoParquet Layer to the document
:param path: The path to the GeoParquet file to embed into the jGIS file.
:param name: The name that will be used for the object in the document.
:param type: The type of the vector layer to create.
:param opacity: The opacity, between 0 and 1.
:param logical_op: The logical combination to apply to filters. Must be "any" or "all"
:param feature: The feature to be filtered on
:param operator: The operator used to compare the feature and value
:param value: The value to be filtered on
:param color_expr: The style expression used to style the layer
"""

source = {
"type": SourceType.GeoParquetSource,
"name": f"{name} Source",
"parameters": {"path": path},
}

source_id = self._add_source(OBJECT_FACTORY.create_source(source, self))

layer = {
"type": LayerType.VectorLayer,
"name": name,
"visible": True,
"parameters": {
"source": source_id,
"type": type,
"opacity": opacity,
"color": color_expr,
},
"filters": {
"appliedFilters": [
{"feature": feature, "operator": operator, "value": value}
],
"logicalOp": logical_op,
},
}

return self._add_layer(OBJECT_FACTORY.create_layer(layer, self))

def remove_layer(self, layer_id: str):
"""
Remove a layer from the GIS document.
Expand Down Expand Up @@ -830,6 +884,7 @@ class Config:
IVideoSource,
IGeoTiffSource,
IRasterDemSource,
IGeoParquetSource,
]
_parent = Optional[GISDocument]

Expand Down Expand Up @@ -917,3 +972,4 @@ def create_source(
OBJECT_FACTORY.register_factory(SourceType.VideoSource, IVideoSource)
OBJECT_FACTORY.register_factory(SourceType.GeoTiffSource, IGeoTiffSource)
OBJECT_FACTORY.register_factory(SourceType.RasterDemSource, IRasterDemSource)
OBJECT_FACTORY.register_factory(SourceType.GeoParquetSource, IGeoParquetSource)
Loading
Loading