diff --git a/packages/base/package.json b/packages/base/package.json index e81ba1df2..abe07868a 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -92,6 +92,7 @@ "proj4": "2.19.3", "proj4-list": "1.0.4", "react": "^18.0.1", + "react-data-grid": "^7.0.0-beta.57", "react-day-picker": "^9.7.0", "shpjs": "^6.1.0", "styled-components": "^5.3.6", diff --git a/packages/base/src/commands/BaseCommandIDs.ts b/packages/base/src/commands/BaseCommandIDs.ts index 95c7491e2..7288c73c8 100644 --- a/packages/base/src/commands/BaseCommandIDs.ts +++ b/packages/base/src/commands/BaseCommandIDs.ts @@ -49,6 +49,7 @@ export const selectCompleter = 'jupytergis:selectConsoleCompleter'; export const addAnnotation = 'jupytergis:addAnnotation'; export const zoomToLayer = 'jupytergis:zoomToLayer'; export const downloadGeoJSON = 'jupytergis:downloadGeoJSON'; +export const openAttributeTable = 'jupytergis:openAttributeTable'; // Panel toggles export const toggleLeftPanel = 'jupytergis:toggleLeftPanel'; diff --git a/packages/base/src/commands/index.ts b/packages/base/src/commands/index.ts index 47ee758b8..3648b77c6 100644 --- a/packages/base/src/commands/index.ts +++ b/packages/base/src/commands/index.ts @@ -22,6 +22,7 @@ import { fromLonLat } from 'ol/proj'; import { CommandIDs, icons } from '../constants'; import { ProcessingFormDialog } from '../dialogs/ProcessingFormDialog'; +import { AttributeTableWidget } from '../dialogs/attributeTable'; import { LayerBrowserWidget } from '../dialogs/layerBrowserDialog'; import { LayerCreationFormDialog } from '../dialogs/layerCreationFormDialog'; import { SymbologyWidget } from '../dialogs/symbology/symbologyDialog'; @@ -850,6 +851,35 @@ export function addCommands( icon: targetWithCenterIcon, }); + commands.addCommand(CommandIDs.openAttributeTable, { + label: trans.__('Open Attribute Table'), + isEnabled: () => { + const selectedLayer = getSingleSelectedLayer(tracker); + return selectedLayer + ? ['VectorLayer', 'VectorTileLayer', 'ShapefileLayer'].includes( + selectedLayer.type, + ) + : false; + }, + execute: async () => { + const currentWidget = tracker.currentWidget; + if (!currentWidget) { + return; + } + + const model = currentWidget.model; + const selectedLayers = model.localState?.selected?.value; + + if (!selectedLayers) { + console.warn('No layer selected'); + return; + } + + const layerId = Object.keys(selectedLayers)[0]; + + Private.createAttributeTableDialog(tracker, layerId)(); + }, + }); // Panel visibility commands commands.addCommand(CommandIDs.toggleLeftPanel, { label: trans.__('Toggle Left Panel'), @@ -1083,6 +1113,21 @@ namespace Private { }; } + export function createAttributeTableDialog( + tracker: JupyterGISTracker, + layerId: string, + ) { + return async () => { + const current = tracker.currentWidget; + if (!current) { + return; + } + + const dialog = new AttributeTableWidget(current.model, layerId); + await dialog.launch(); + }; + } + export function createEntry({ tracker, formSchemaRegistry, diff --git a/packages/base/src/dialogs/attributeTable.tsx b/packages/base/src/dialogs/attributeTable.tsx new file mode 100644 index 000000000..53beafec1 --- /dev/null +++ b/packages/base/src/dialogs/attributeTable.tsx @@ -0,0 +1,82 @@ +import { IJupyterGISModel } from '@jupytergis/schema'; +import { Dialog } from '@jupyterlab/apputils'; +import React from 'react'; +import DataGrid from 'react-data-grid'; + +import { useGetFeatures } from './symbology/hooks/useGetFeatures'; + +export interface IAttributeTableProps { + model: IJupyterGISModel; + layerId: string; +} + +const AttributeTable: React.FC = ({ model, layerId }) => { + const [columns, setColumns] = React.useState([]); + const [rows, setRows] = React.useState([]); + + const { features, isLoading, error } = useGetFeatures({ layerId, model }); + + React.useEffect(() => { + if (isLoading) { + return; + } + if (error) { + console.error('[AttributeTable] Error loading features:', error); + return; + } + if (!features.length) { + console.warn('[AttributeTable] No features found.'); + setColumns([]); + setRows([]); + return; + } + + const sampleProps = features[0]?.properties ?? {}; + const cols = [ + { key: 'sno', name: 'S. No.', resizable: true, sortable: true }, + ...Object.keys(sampleProps).map(key => ({ + key, + name: key, + resizable: true, + sortable: true, + })), + ]; + + const rowData = features.map((f, i) => ({ + sno: i + 1, + ...f.properties, + })); + + setColumns(cols); + setRows(rowData); + }, [features, isLoading, error]); + + return ( +
+ +
+ ); +}; + +export class AttributeTableWidget extends Dialog { + constructor(model: IJupyterGISModel, layerId: string) { + const body = ( +
+ +
+ ); + + super({ + title: 'Attribute Table', + body, + }); + + this.id = 'jupytergis::attributeTable'; + this.addClass('jp-gis-attribute-table-dialog'); + } +} diff --git a/packages/base/src/dialogs/symbology/hooks/useGetFeatures.ts b/packages/base/src/dialogs/symbology/hooks/useGetFeatures.ts new file mode 100644 index 000000000..9ba174bc8 --- /dev/null +++ b/packages/base/src/dialogs/symbology/hooks/useGetFeatures.ts @@ -0,0 +1,61 @@ +import { GeoJSONFeature1, IJupyterGISModel } from '@jupytergis/schema'; +import { useEffect, useState } from 'react'; + +import { loadFile } from '@/src/tools'; + +interface IUseGetFeaturesProps { + layerId?: string; + model: IJupyterGISModel; +} + +interface IUseGetFeaturesResult { + features: GeoJSONFeature1[]; + isLoading: boolean; + error?: Error; +} + +export const useGetFeatures = ({ + layerId, + model, +}: IUseGetFeaturesProps): IUseGetFeaturesResult => { + const [features, setFeatures] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + const fetchFeatures = async () => { + if (!layerId) { + return; + } + + try { + const layer = model.getLayer(layerId); + const source = model.getSource(layer?.parameters?.source); + + if (!source) { + throw new Error('Source not found'); + } + + const data = await loadFile({ + filepath: source.parameters?.path, + type: 'GeoJSONSource', + model: model, + }); + + if (!data) { + throw new Error('Failed to read GeoJSON data'); + } + + setFeatures(data.features || []); + } catch (err) { + setError(err as Error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchFeatures(); + }, [model, layerId]); + + return { features, isLoading, error }; +}; diff --git a/python/jupytergis_lab/src/index.ts b/python/jupytergis_lab/src/index.ts index 33c76502d..4370215d0 100644 --- a/python/jupytergis_lab/src/index.ts +++ b/python/jupytergis_lab/src/index.ts @@ -116,6 +116,12 @@ const plugin: JupyterFrontEndPlugin = { rank: 2, }); + app.contextMenu.addItem({ + command: CommandIDs.openAttributeTable, + selector: '.jp-gis-layerItem', + rank: 2, + }); + // Create the Download submenu const downloadSubmenu = new Menu({ commands: app.commands }); downloadSubmenu.title.label = translator.load('jupyterlab').__('Download'); diff --git a/yarn.lock b/yarn.lock index 0fdc87116..515dfd2ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -929,6 +929,7 @@ __metadata: proj4: 2.19.3 proj4-list: 1.0.4 react: ^18.0.1 + react-data-grid: ^7.0.0-beta.57 react-day-picker: ^9.7.0 rimraf: ^3.0.2 shpjs: ^6.1.0 @@ -5029,6 +5030,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^1.1.1": + version: 1.2.1 + resolution: "clsx@npm:1.2.1" + checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 + languageName: node + linkType: hard + "clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" @@ -10649,6 +10657,18 @@ __metadata: languageName: node linkType: hard +"react-data-grid@npm:^7.0.0-beta.57": + version: 7.0.0-canary.49 + resolution: "react-data-grid@npm:7.0.0-canary.49" + dependencies: + clsx: ^1.1.1 + peerDependencies: + react: ^16.14 || ^17.0 + react-dom: ^16.14 || ^17.0 + checksum: fe57d441a5e56dac39a0f7e06979a27d5606515653ead31fc06f9c2fe08ebaf88ff4ab70f1565dd356b343cf4612137dea2d188a40a7e246b85dd5aa2589da04 + languageName: node + linkType: hard + "react-day-picker@npm:^9.7.0": version: 9.9.0 resolution: "react-day-picker@npm:9.9.0"