Repository: geojupyter/jupytergis (language: python)

Issue:
Implement some well known data sources
As part of #9 we should implement some well known types of sources

- [x] Raster tiles from url #10 
- [x] Vector tiles from url
- [x] GeoJSON
- [x] ShapeFile
- [ ] WMS
- [ ] SpatialLite
- [x] Image
- [x] Video


Discussion / hints:

Fix patch:
diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts
index 57f6e2a9..f77a1f1e 100644
--- a/packages/base/src/commands.ts
+++ b/packages/base/src/commands.ts
@@ -128,6 +128,32 @@ export function addCommands(
     execute: Private.createRasterLayer(tracker, formSchemaRegistry)
   });
 
+  commands.addCommand(CommandIDs.newVideoLayer, {
+    label: args =>
+      args.from === 'contextMenu'
+        ? trans.__('Video')
+        : trans.__('Add new video layer'),
+    isEnabled: () => {
+      return tracker.currentWidget
+        ? tracker.currentWidget.context.model.sharedModel.editable
+        : false;
+    },
+    execute: Private.createVideoLayer(tracker, formSchemaRegistry)
+  });
+
+  commands.addCommand(CommandIDs.newImageLayer, {
+    label: args =>
+      args.from === 'contextMenu'
+        ? trans.__('Image')
+        : trans.__('Add new image layer'),
+    isEnabled: () => {
+      return tracker.currentWidget
+        ? tracker.currentWidget.context.model.sharedModel.editable
+        : false;
+    },
+    execute: Private.createImageLayer(tracker, formSchemaRegistry)
+  });
+
   /**
    * SOURCES only commands.
    */
@@ -145,6 +171,34 @@ export function addCommands(
     ...icons.get(CommandIDs.newGeoJSONSource)?.icon
   });
 
+  commands.addCommand(CommandIDs.newVideoSource, {
+    label: args =>
+      args.from === 'contextMenu'
+        ? trans.__('Video')
+        : trans.__('Add Video Source'),
+    isEnabled: () => {
+      return tracker.currentWidget
+        ? tracker.currentWidget.context.model.sharedModel.editable
+        : false;
+    },
+    execute: Private.createVideoSource(tracker, formSchemaRegistry)
+    // ...icons.get(CommandIDs.newGeoJSONSource)?.icon
+  });
+
+  commands.addCommand(CommandIDs.newImageSource, {
+    label: args =>
+      args.from === 'contextMenu'
+        ? trans.__('image')
+        : trans.__('Add image Source'),
+    isEnabled: () => {
+      return tracker.currentWidget
+        ? tracker.currentWidget.context.model.sharedModel.editable
+        : false;
+    },
+    execute: Private.createImageSource(tracker, formSchemaRegistry)
+    // ...icons.get(CommandIDs.newGeoJSONSource)?.icon
+  });
+
   commands.addCommand(CommandIDs.removeSource, {
     label: trans.__('Remove Source'),
     execute: () => {
@@ -544,6 +598,75 @@ namespace Private {
     };
   }
 
+  export function createVideoSource(
+    tracker: WidgetTracker<JupyterGISWidget>,
+    formSchemaRegistry: IJGISFormSchemaRegistry
+  ) {
+    return async () => {
+      const current = tracker.currentWidget;
+
+      if (!current) {
+        return;
+      }
+
+      const dialog = new CreationFormDialog({
+        context: current.context,
+        title: 'Create Video Source',
+        createLayer: false,
+        createSource: true,
+        sourceData: {
+          name: 'Custom Video Source',
+          urls: [
+            'https://static-assets.mapbox.com/mapbox-gl-js/drone.mp4',
+            'https://static-assets.mapbox.com/mapbox-gl-js/drone.webm'
+          ],
+          coordinates: [
+            [-122.51596391201019, 37.56238816766053],
+            [-122.51467645168304, 37.56410183312965],
+            [-122.51309394836426, 37.563391708549425],
+            [-122.51423120498657, 37.56161849366671]
+          ]
+        },
+        sourceType: 'VideoSource',
+        formSchemaRegistry
+      });
+      await dialog.launch();
+    };
+  }
+
+  export function createImageSource(
+    tracker: WidgetTracker<JupyterGISWidget>,
+    formSchemaRegistry: IJGISFormSchemaRegistry
+  ) {
+    return async () => {
+      const current = tracker.currentWidget;
+
+      if (!current) {
+        return;
+      }
+
+      const dialog = new CreationFormDialog({
+        context: current.context,
+        title: 'Create image Source',
+        createLayer: false,
+        createSource: true,
+        sourceData: {
+          name: 'Custom image Source',
+          url: 'https://maplibre.org/maplibre-gl-js/docs/assets/radar.gif',
+          coordinates: [
+            [-80.425, 46.437],
+            [-71.516, 46.437],
+            [-71.516, 37.936],
+            [-80.425, 37.936]
+          ]
+        },
+        sourceType: 'ImageSource',
+        formSchemaRegistry
+      });
+      await dialog.launch();
+    };
+  }
+
   /**
    * Command to create a Vector layer.
    *
@@ -603,6 +726,60 @@ namespace Private {
     };
   }
 
+  export function createVideoLayer(
+    tracker: WidgetTracker<JupyterGISWidget>,
+    formSchemaRegistry: IJGISFormSchemaRegistry
+  ) {
+    return async () => {
+      const current = tracker.currentWidget;
+
+      if (!current) {
+        return;
+      }
+
+      const dialog = new CreationFormDialog({
+        context: current.context,
+        title: 'Create Video Layer',
+        createLayer: true,
+        createSource: false,
+        layerData: {
+          name: 'Custom Video Layer'
+        },
+        sourceType: 'VideoSource',
+        layerType: 'RasterLayer',
+        formSchemaRegistry
+      });
+      await dialog.launch();
+    };
+  }
+
+  export function createImageLayer(
+    tracker: WidgetTracker<JupyterGISWidget>,
+    formSchemaRegistry: IJGISFormSchemaRegistry
+  ) {
+    return async () => {
+      const current = tracker.currentWidget;
+
+      if (!current) {
+        return;
+      }
+
+      const dialog = new CreationFormDialog({
+        context: current.context,
+        title: 'Create Image Layer',
+        createLayer: true,
+        createSource: false,
+        layerData: {
+          name: 'Custom Image Layer'
+        },
+        sourceType: 'ImageSource',
+        layerType: 'RasterLayer',
+        formSchemaRegistry
+      });
+      await dialog.launch();
+    };
+  }
+
   export async function getUserInputForRename(
     text: HTMLElement,
     input: HTMLInputElement,
diff --git a/packages/base/src/constants.ts b/packages/base/src/constants.ts
index e3556942..e1a65164 100644
--- a/packages/base/src/constants.ts
+++ b/packages/base/src/constants.ts
@@ -21,9 +21,13 @@ export namespace CommandIDs {
   export const newRasterDemSource = 'jupytergis:newRasterDemSource';
   export const removeSource = 'jupytergis:removeSource';
   export const renameSource = 'jupytergis:renameSource';
+  export const newVideoSource = 'jupytergis:videoSource';
+  export const newImageSource = 'jupytergis:imageSource';
 
   // Layers only commands
   export const newVectorLayer = 'jupytergis:newVectorLayer';
+  export const newVideoLayer = 'jupytergis:newVideoLayer';
+  export const newImageLayer = 'jupytergis:newImageLayer';
 
   export const renameLayer = 'jupytergis:renameLayer';
   export const removeLayer = 'jupytergis:removeLayer';
diff --git a/packages/base/src/formbuilder/objectform/baseform.tsx b/packages/base/src/formbuilder/objectform/baseform.tsx
index 2339b015..221df9ac 100644
--- a/packages/base/src/formbuilder/objectform/baseform.tsx
+++ b/packages/base/src/formbuilder/objectform/baseform.tsx
@@ -134,6 +134,21 @@ export class BaseForm extends React.Component<IBaseFormProps, IBaseFormStates> {
           },
           ...uiSchema[k]
         };
+
+        if (v['items']['type'] === 'array') {
+          uiSchema[k] = {
+            items: {
+              'ui:options': {
+                orderable: false,
+                removable: false,
+                addable: false
+              },
+              ...uiSchema[k]['items']
+            },
+
+            ...uiSchema[k]
+          };
+        }
       }
 
       if (v['type'] === 'object') {
diff --git a/packages/base/src/mainview/mainview.tsx b/packages/base/src/mainview/mainview.tsx
index 647adfc7..c630a485 100644
--- a/packages/base/src/mainview/mainview.tsx
+++ b/packages/base/src/mainview/mainview.tsx
@@ -1,6 +1,7 @@
 import { MapChange } from '@jupyter/ydoc';
 import {
   IHillshadeLayer,
+  IImageSource,
   IJGISLayer,
   IJGISLayerDocChange,
   IJGISLayerTreeDocChange,
@@ -15,6 +16,7 @@ import {
   IRasterSource,
   IVectorLayer,
   IVectorTileSource,
+  IVideoSource,
   JupyterGISModel
 } from '@jupytergis/schema';
 import { showErrorMessage } from '@jupyterlab/apputils';
@@ -214,9 +216,44 @@ export class MainView extends React.Component<IProps, IStates> {
         }
         break;
       }
+      case 'VideoSource': {
+        const mapSource = this._Map.getSource(id) as MapLibre.VideoSource;
+        if (!mapSource) {
+          const parameters = source.parameters as IVideoSource;
+          this._Map
+            .addSource(id, {
+              type: 'video',
+              urls: parameters?.urls,
+              coordinates: parameters?.coordinates
+            })
+            .on('click', () => this.toggleVideoPlaying(id));
+
+          this._videoPlaying = true;
+        }
+        break;
+      }
+      case 'ImageSource': {
+        const mapSource = this._Map.getSource(id) as MapLibre.ImageSource;
+        if (!mapSource) {
+          const parameters = source.parameters as IImageSource;
+          this._Map.addSource(id, {
+            type: 'image',
+            url: parameters?.url,
+            coordinates: parameters?.coordinates
+          });
+        }
+        break;
+      }
     }
   }
 
+  private toggleVideoPlaying(sourceId: any) {
+    const source = this._Map.getSource(sourceId) as MapLibre.VideoSource;
+
+    this._videoPlaying ? source?.pause() : source?.play();
+    this._videoPlaying = !this._videoPlaying;
+  }
+
   private computeSourceUrl(source: IJGISSource): string {
     const parameters = source.parameters as IRasterSource;
     const urlParameters = parameters.urlParameters || {};
@@ -275,6 +312,21 @@ export class MainView extends React.Component<IProps, IStates> {
         (mapSource as MapLibre.RasterDEMTileSource).setTiles([parameters.url]);
         break;
       }
+      case 'ImageSource': {
+        const parameters = source.parameters as IImageSource;
+        (mapSource as MapLibre.ImageSource).updateImage({
+          url: parameters.url,
+          coordinates: parameters.coordinates
+        });
+        break;
+      }
+      case 'VideoSource': {
+        const parameters = source.parameters as IVideoSource;
+        (mapSource as MapLibre.VideoSource).setCoordinates(
+          parameters.coordinates
+        );
+        break;
+      }
       default: {
         console.warn('Source type not found');
       }
@@ -288,6 +340,11 @@ export class MainView extends React.Component<IProps, IStates> {
    */
   removeSource(id: string): void {
     const mapSource = this._Map.getSource(id);
+    if (mapSource?.type === 'video') {
+      (mapSource as MapLibre.VideoSource).off('click', () =>
+        this.toggleVideoPlaying(id)
+      );
+    }
     if (mapSource) {
       this._Map.removeSource(id);
     }
@@ -691,7 +748,7 @@ export class MainView extends React.Component<IProps, IStates> {
     }
 
     change.sourceChange?.forEach(change => {
-      if (!change.newValue) {
+      if (!change.newValue || Object.keys(change.newValue).length === 0) {
         this.removeSource(change.id);
       } else {
         const source = this._model.getSource(change.id);
@@ -762,4 +819,5 @@ export class MainView extends React.Component<IProps, IStates> {
   private _mainViewModel: MainViewModel;
   private _ready = false;
   private _terrainControl: MapLibre.TerrainControl | null;
+  private _videoPlaying = false;
 }
diff --git a/packages/base/src/toolbar/widget.tsx b/packages/base/src/toolbar/widget.tsx
index 3fe09d70..4921224b 100644
--- a/packages/base/src/toolbar/widget.tsx
+++ b/packages/base/src/toolbar/widget.tsx
@@ -91,6 +91,51 @@ export class ToolbarWidget extends Toolbar {
         })
       );
 
+      this.addItem(
+        'newImageSource',
+        new CommandToolbarButton({
+          id: CommandIDs.newImageSource,
+          label: '',
+          commands: options.commands
+        })
+      );
+
+      this.addItem(
+        'newImageLayer',
+        new CommandToolbarButton({
+          id: CommandIDs.newImageLayer,
+          label: '',
+          commands: options.commands
+        })
+      );
+
+      this.addItem(
+        'newVideoSource',
+        new CommandToolbarButton({
+          id: CommandIDs.newVideoSource,
+          label: '',
+          commands: options.commands
+        })
+      );
+
+      this.addItem(
+        'newVideoLayer',
+        new CommandToolbarButton({
+          id: CommandIDs.newVideoLayer,
+          label: '',
+          commands: options.commands
+        })
+      );
+
+      this.addItem(
+        'newRasterLayer',
+        new CommandToolbarButton({
+          id: CommandIDs.newRasterLayer,
+          label: '',
+          commands: options.commands
+        })
+      );
+
       // Add more commands here
 
       this.addItem('spacer', Toolbar.createSpacerItem());
diff --git a/packages/base/style/base.css b/packages/base/style/base.css
index 47ee9a32..751bd8d1 100644
--- a/packages/base/style/base.css
+++ b/packages/base/style/base.css
@@ -17,17 +17,18 @@
 /* Overwrite forms CSS */
 .jGIS-property-panel .array-item-list {
   display: flex;
+  gap: 1rem;
 }
 
 .jGIS-property-panel .jp-SchemaForm .array-item:not(:last-child) {
-  margin-right: var(--jp-ui-font-size1);
   border-bottom: none;
   margin-bottom: unset;
   padding-bottom: unset;
 }
 
 .jGIS-property-panel .jp-SchemaForm .array-item {
-  flex-grow: 1;
+  flex: 1 0 0%;
+  align-items: center;
 }
 
 .jGIS-property-panel .jp-SchemaForm .field-description {
@@ -38,3 +39,7 @@
   padding-left: unset;
   border-left: none;
 }
+
+.jGIS-property-panel .jp-SchemaForm .array-item-list:has(.field-array) {
+  flex-direction: column;
+}
diff --git a/packages/schema/src/schema/imageSource.json b/packages/schema/src/schema/imageSource.json
new file mode 100644
index 00000000..22aab46a
--- /dev/null
+++ b/packages/schema/src/schema/imageSource.json
@@ -0,0 +1,30 @@
+{
+  "type": "object",
+  "description": "ImageSource",
+  "title": "IImageSource",
+  "required": ["url", "coordinates"],
+  "additionalProperties": false,
+  "properties": {
+    "url": {
+      "type": "string",
+      "readOnly": true,
+      "description": "URL that points to an image"
+    },
+    "coordinates": {
+      "type": "array",
+      "readOnly": true,
+      "items": {
+        "type": "array",
+        "items": {
+          "type": "number"
+        },
+        "minItems": 2,
+        "maxItems": 2
+      },
+      "minItems": 4,
+      "maxItems": 4,
+      "default": [],
+      "description": "Corners of image specified in longitude, latitude pairs"
+    }
+  }
+}
diff --git a/packages/schema/src/schema/jgis.json b/packages/schema/src/schema/jgis.json
index e39530f2..565a6627 100644
--- a/packages/schema/src/schema/jgis.json
+++ b/packages/schema/src/schema/jgis.json
@@ -31,7 +31,9 @@
         "RasterSource",
         "VectorTileSource",
         "GeoJSONSource",
-        "RasterDemSource"
+        "RasterDemSource",
+        "VideoSource",
+        "ImageSource"
       ]
     },
     "jGISLayer": {
diff --git a/packages/schema/src/schema/rasterlayer.json b/packages/schema/src/schema/rasterlayer.json
index dcff7578..c42509cb 100644
--- a/packages/schema/src/schema/rasterlayer.json
+++ b/packages/schema/src/schema/rasterlayer.json
@@ -7,8 +7,7 @@
   "properties": {
     "source": {
       "type": "string",
-      "description": "The id of the source",
-      "default": ""
+      "description": "The id of the source"
     },
     "opacity": {
       "type": "number",
diff --git a/packages/schema/src/schema/videoSource.json b/packages/schema/src/schema/videoSource.json
new file mode 100644
index 00000000..5d5b6ca0
--- /dev/null
+++ b/packages/schema/src/schema/videoSource.json
@@ -0,0 +1,34 @@
+{
+  "type": "object",
+  "description": "VideoSource",
+  "title": "IVideoSource",
+  "required": ["urls", "coordinates"],
+  "additionalProperties": false,
+  "properties": {
+    "urls": {
+      "type": "array",
+      "readOnly": true,
+      "items": {
+        "type": "string"
+      },
+      "default": [],
+      "description": "URLs to video content in order of preferred format"
+    },
+    "coordinates": {
+      "type": "array",
+      "readOnly": true,
+      "items": {
+        "type": "array",
+        "items": {
+          "type": "number"
+        },
+        "minItems": 2,
+        "maxItems": 2
+      },
+      "minItems": 4,
+      "maxItems": 4,
+      "default": [],
+      "description": "Corners of video specified in longitude, latitude pairs"
+    }
+  }
+}
diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts
index 593dcc73..45741114 100644
--- a/packages/schema/src/types.ts
+++ b/packages/schema/src/types.ts
@@ -1,12 +1,20 @@
 export * from './_interface/jgis';
-export * from './_interface/rasterlayer';
-export * from './_interface/vectorlayer';
-export * from './_interface/rastersource';
-export * from './_interface/vectortilesource';
+
+// Sources
 export * from './_interface/geojsonsource';
+export * from './_interface/imageSource';
 export * from './_interface/rasterDemSource';
+export * from './_interface/rastersource';
+export * from './_interface/vectortilesource';
+export * from './_interface/videoSource';
+
+// Layers
 export * from './_interface/hillshadeLayer';
+export * from './_interface/rasterlayer';
+export * from './_interface/vectorlayer';
+
+// Other
+export * from './doc';
 export * from './interfaces';
 export * from './model';
-export * from './doc';
 export * from './token';
diff --git a/python/jupytergis_lab/src/index.ts b/python/jupytergis_lab/src/index.ts
index c95997e7..5b021aa8 100644
--- a/python/jupytergis_lab/src/index.ts
+++ b/python/jupytergis_lab/src/index.ts
@@ -90,6 +90,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
       args: { from: 'contextMenu' }
     });
 
+    newSourceSubMenu.addItem({
+      command: CommandIDs.newVideoSource,
+      args: { from: 'contextMenu' }
+    });
+
+    newSourceSubMenu.addItem({
+      command: CommandIDs.newImageSource,
+      args: { from: 'contextMenu' }
+    });
+
     app.contextMenu.addItem({
       type: 'separator',
       selector: '.jp-gis-sourcePanel',
@@ -213,6 +223,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
       args: { from: 'contextMenu' }
     });
 
+    newLayerSubMenu.addItem({
+      command: CommandIDs.newImageLayer,
+      args: { from: 'contextMenu' }
+    });
+
+    newLayerSubMenu.addItem({
+      command: CommandIDs.newVideoLayer,
+      args: { from: 'contextMenu' }
+    });
+
     if (mainMenu) {
       populateMenus(mainMenu, isEnabled);
     }


Test patch:
diff --git a/examples/video_test.jGIS b/examples/video_test.jGIS
new file mode 100644
index 00000000..c3d8c7f8
--- /dev/null
+++ b/examples/video_test.jGIS
@@ -0,0 +1,113 @@
+{
+  "layerTree": [
+    "acb76fb7-df6d-41da-8e08-0208a1c82136",
+    "6234f9f0-1274-445f-aa48-686a578e8cc8",
+    "756cb737-f817-4ba6-b7c0-8da0b97b9778"
+  ],
+  "layers": {
+    "6234f9f0-1274-445f-aa48-686a578e8cc8": {
+      "name": "Custom video Layer Layer",
+      "parameters": {
+        "opacity": 1.0,
+        "source": "319c8756-ffee-45eb-8bbc-36a32526a9f6"
+      },
+      "type": "RasterLayer",
+      "visible": true
+    },
+    "756cb737-f817-4ba6-b7c0-8da0b97b9778": {
+      "name": "Custom Image Layer Layer",
+      "parameters": {
+        "opacity": 1.0,
+        "source": "fb9729b8-82c6-48ac-a12b-6343c0e037ae"
+      },
+      "type": "RasterLayer",
+      "visible": true
+    },
+    "acb76fb7-df6d-41da-8e08-0208a1c82136": {
+      "name": "OpenStreetMap.Mapnik Layer",
+      "parameters": {
+        "source": "5f7a1edf-1b76-4f82-893f-540d28998b1d"
+      },
+      "type": "RasterLayer",
+      "visible": true
+    }
+  },
+  "options": {
+    "bearing": -96.0,
+    "latitude": 37.562984,
+    "longitude": -122.514426,
+    "pitch": 0.0,
+    "zoom": 17.0
+  },
+  "sources": {
+    "319c8756-ffee-45eb-8bbc-36a32526a9f6": {
+      "name": "Custom Video Source",
+      "parameters": {
+        "coordinates": [
+          [
+            -122.51596391201019,
+            37.56238816766053
+          ],
+          [
+            -122.51467645168304,
+            37.56410183312965
+          ],
+          [
+            -122.51309394836426,
+            37.563391708549425
+          ],
+          [
+            -122.51423120498657,
+            37.56161849366671
+          ]
+        ],
+        "urls": [
+          "https://static-assets.mapbox.com/mapbox-gl-js/drone.mp4",
+          "https://static-assets.mapbox.com/mapbox-gl-js/drone.webm"
+        ]
+      },
+      "type": "VideoSource"
+    },
+    "5f7a1edf-1b76-4f82-893f-540d28998b1d": {
+      "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"
+    },
+    "fb9729b8-82c6-48ac-a12b-6343c0e037ae": {
+      "name": "Custom image Source",
+      "parameters": {
+        "coordinates": [
+          [
+            -80.425,
+            46.437
+          ],
+          [
+            -71.516,
+            46.437
+          ],
+          [
+            -71.516,
+            37.936
+          ],
+          [
+            -80.425,
+            37.936
+          ]
+        ],
+        "url": "https://maplibre.org/maplibre-gl-js/docs/assets/radar.gif"
+      },
+      "type": "ImageSource"
+    }
+  },
+  "terrain": {
+    "exaggeration": 0.0,
+    "source": ""
+  }
+}
\ No newline at end of file